defmodule AprsmeWeb.InfoLive.Show do
  @moduledoc false
  use AprsmeWeb, :live_view
  use Gettext, backend: AprsmeWeb.Gettext

  import AprsmeWeb.Components.InfoMapComponent
  import Ecto.Query
  import Phoenix.HTML, only: [raw: 1]

  alias Aprsme.Callsign
  alias Aprsme.EncodingUtils
  alias Aprsme.GeoUtils
  alias Aprsme.Packets
  alias Aprsme.Repo
  alias AprsmeWeb.AprsSymbol
  alias AprsmeWeb.Live.SharedPacketHandler
  alias AprsmeWeb.MapLive.PacketUtils

  require Logger

  @neighbor_limit 10

  # APRS Q-construct descriptions (APRS-IS codes)
  @q_constructs %{
    "qAC" => "qAC (APRS-IS connection)",
    "qAO" => "qAO (APRS-IS origin)",
    "qAR" => "qAR (APRS-IS relay)",
    "qAS" => "qAS (APRS-IS server)",
    "qAX" => "qAX (APRS-IS client)",
    "qAY" => "qAY (APRS-IS gateway)",
    "qAZ" => "qAZ (APRS-IS zone)",
    "qBU" => "qBU (APRS-IS user)",
    "qBV" => "qBV (APRS-IS vendor)",
    "qBW" => "qBW (APRS-IS web)",
    "qBX" => "qBX (APRS-IS experimental)",
    "qBY" => "qBY (APRS-IS Y2K)",
    "qBZ" => "qBZ (APRS-IS Zulu)",
    "qCA" => "qCA (APRS-IS client application)",
    "qCB" => "qCB (APRS-IS client browser)",
    "qCC" => "qCC (APRS-IS client console)",
    "qCD" => "qCD (APRS-IS client daemon)",
    "qCE" => "qCE (APRS-IS client editor)",
    "qCF" => "qCF (APRS-IS client filter)",
    "qCG" => "qCG (APRS-IS client gateway)",
    "qCH" => "qCH (APRS-IS client host)",
    "qCI" => "qCI (APRS-IS client interface)",
    "qCJ" => "qCJ (APRS-IS client java)",
    "qCK" => "qCK (APRS-IS client kernel)",
    "qCL" => "qCL (APRS-IS client library)",
    "qCM" => "qCM (APRS-IS client module)",
    "qCN" => "qCN (APRS-IS client network)",
    "qCO" => "qCO (APRS-IS client object)",
    "qCP" => "qCP (APRS-IS client protocol)",
    "qCQ" => "qCQ (APRS-IS client query)",
    "qCR" => "qCR (APRS-IS client router)",
    "qCS" => "qCS (APRS-IS client server)",
    "qCT" => "qCT (APRS-IS client terminal)",
    "qCU" => "qCU (APRS-IS client user)",
    "qCV" => "qCV (APRS-IS client vendor)",
    "qCW" => "qCW (APRS-IS client web)",
    "qCX" => "qCX (APRS-IS client experimental)",
    "qCY" => "qCY (APRS-IS client Y2K)",
    "qCZ" => "qCZ (APRS-IS client Zulu)"
  }

  @impl true
  def mount(%{"callsign" => callsign}, _session, socket) do
    normalized_callsign = Callsign.normalize(callsign)

    # Subscribe to callsign-specific topic for live updates
    _ =
      if connected?(socket) do
        Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
      end

    packet = get_latest_packet(normalized_callsign)
    packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet)
    # Get locale from socket assigns (set by LocaleHook)
    locale = Map.get(socket.assigns, :locale, "en")
    neighbors = get_neighbors(packet, normalized_callsign, locale)
    has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
    other_ssids = Packets.get_other_ssids(normalized_callsign)

    socket =
      socket
      |> assign(:callsign, normalized_callsign)
      |> assign(:packet, packet)
      |> assign(:neighbors, neighbors)
      |> assign(:heard_by_stations, [])
      |> assign(:stations_heard_by, [])
      |> assign(:page_title, "APRS station #{normalized_callsign}")
      |> assign(:has_weather_packets, has_weather_packets)
      |> assign(:other_ssids, other_ssids)

    send(self(), :load_aggregations)

    {:ok, socket}
  end

  @impl true
  def handle_info({:postgres_packet, packet}, socket) do
    # Since we're subscribed to callsign-specific topic, no need to filter
    # The packet from PostgreSQL notify already contains all fields
    process_packet_update(packet, socket)
  end

  @impl true
  def handle_info(:load_aggregations, socket) do
    locale = Map.get(socket.assigns, :locale, "en")
    heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale)
    stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale)

    {:noreply, assign(socket, heard_by_stations: heard_by_stations, stations_heard_by: stations_heard_by)}
  end

  def handle_info(_message, socket), do: {:noreply, socket}

  defp process_packet_update(incoming_packet, socket) do
    Logger.debug(
      "InfoLive received packet update for #{socket.assigns.callsign}: #{inspect(Map.get(incoming_packet, "raw_packet"))}"
    )

    # Get the new packet data
    new_packet = get_latest_packet(socket.assigns.callsign)
    new_packet = if new_packet, do: SharedPacketHandler.enrich_with_device_info(new_packet)

    current_packet = socket.assigns.packet

    # Check if this is a position update by comparing location and other key fields
    position_changed = position_changed?(current_packet, new_packet)

    if position_changed do
      # Only update position-related data if position changed
      # Get locale from socket assigns
      locale = Map.get(socket.assigns, :locale, "en")
      neighbors = get_neighbors(new_packet, socket.assigns.callsign, locale)

      socket =
        socket
        |> assign(:packet, new_packet)
        |> assign(:neighbors, neighbors)

      {:noreply, socket}
    else
      # Just update the packet data (for timestamp, comment, etc.) without affecting neighbors
      socket = assign(socket, :packet, new_packet)

      {:noreply, socket}
    end
  end

  @spec position_changed?(map() | nil, map() | nil) :: boolean()
  defp position_changed?(nil, _new_packet), do: true
  defp position_changed?(_current_packet, nil), do: false

  defp position_changed?(current_packet, new_packet) do
    coords_changed?({
      EncodingUtils.to_float(current_packet.lat),
      EncodingUtils.to_float(current_packet.lon),
      EncodingUtils.to_float(new_packet.lat),
      EncodingUtils.to_float(new_packet.lon)
    })
  end

  @spec coords_changed?({float() | nil, float() | nil, float() | nil, float() | nil}) :: boolean()
  defp coords_changed?({curr_lat, curr_lon, new_lat, new_lon})
       when is_number(curr_lat) and is_number(curr_lon) and is_number(new_lat) and is_number(new_lon) do
    threshold = Application.get_env(:aprsme, :position_tracking, [])[:change_threshold] || 0.001
    abs(curr_lat - new_lat) > threshold or abs(curr_lon - new_lon) > threshold
  end

  defp coords_changed?(_), do: true

  # Expose for testing
  if Mix.env() == :test do
    def position_changed_for_test(current, new), do: position_changed?(current, new)
  end

  defp get_latest_packet(callsign) do
    # Get the most recent packet for this callsign, regardless of type
    # This ensures we show the most recent activity, not just position packets
    packet = Packets.get_latest_packet_for_callsign(callsign)

    # If this packet doesn't have position data, try to get the latest position packet
    if packet && (is_nil(packet.lat) || is_nil(packet.lon)) do
      position_packet = get_latest_position_packet(callsign)

      if position_packet do
        # Merge position data from position packet into the latest packet
        %{packet | lat: position_packet.lat, lon: position_packet.lon, location: position_packet.location}
      else
        packet
      end
    else
      packet
    end
  end

  defp get_latest_position_packet(callsign) do
    # Get the most recent packet with valid position data
    Repo.one(
      from(p in Aprsme.Packet,
        where: fragment("upper(?)", p.sender) == ^String.upcase(String.trim(callsign)),
        where: not is_nil(p.lat) and not is_nil(p.lon),
        order_by: [desc: p.received_at],
        limit: 1,
        select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
      )
    )
  end

  defp get_neighbors(nil, _callsign, _locale), do: []

  defp get_neighbors(packet, callsign, locale) do
    lat = packet.lat
    lon = packet.lon

    if is_nil(lat) or is_nil(lon) do
      []
    else
      # Convert Decimal to float if needed
      lat_float = to_float(lat)
      lon_float = to_float(lon)

      # Use the spatial query to get the closest stations
      # The query already returns them sorted by distance
      nearby_packets =
        Packets.get_nearby_stations(
          lat_float,
          lon_float,
          callsign,
          %{limit: @neighbor_limit}
        )

      # Calculate distance and course for each neighbor
      # The packets are already sorted by distance from the database
      Enum.map(nearby_packets, fn p ->
        dist = haversine(lat, lon, p.lat, p.lon)
        course = calculate_course(lat, lon, p.lat, p.lon)

        %{
          callsign: p.sender,
          distance: format_distance(dist, locale),
          distance_km: dist,
          course: course,
          last_heard: format_timestamp_for_display(p),
          packet: p
        }
      end)
    end
  end

  @typep timestamp_display :: %{
           time_ago: String.t(),
           formatted: String.t(),
           timestamp: String.t() | nil
         }

  @spec haversine(any(), any(), any(), any()) :: float()
  def haversine(lat1, lon1, lat2, lon2) do
    # Returns distance in km. Decimals are converted to float; nil/invalid inputs become 0.0.
    GeoUtils.haversine_distance(to_float(lat1), to_float(lon1), to_float(lat2), to_float(lon2)) / 1000
  end

  @spec to_float(any()) :: float()
  defp to_float(value), do: EncodingUtils.to_float(value) || 0.0

  @spec format_timestamp_for_display(map()) :: timestamp_display()
  defp format_timestamp_for_display(packet) do
    packet
    |> get_received_at()
    |> AprsmeWeb.TimeHelpers.to_datetime()
    |> build_timestamp_display()
  end

  @spec build_timestamp_display(DateTime.t() | nil) :: timestamp_display()
  defp build_timestamp_display(nil), do: %{time_ago: gettext("Unknown"), formatted: "", timestamp: nil}

  defp build_timestamp_display(%DateTime{} = dt) do
    %{
      time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(dt),
      formatted: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC"),
      timestamp: DateTime.to_iso8601(dt)
    }
  end

  @spec get_received_at(map()) :: DateTime.t() | NaiveDateTime.t() | String.t() | integer() | nil
  defp get_received_at(%{received_at: value}), do: value
  defp get_received_at(%{"received_at" => value}), do: value
  defp get_received_at(_), do: nil

  # English locale uses imperial (ft/mi), everything else uses metric (m/km).
  def format_distance(km, locale \\ "en")

  def format_distance(km, "en") do
    miles = Aprsme.Convert.kph_to_mph(km)
    format_imperial_distance(miles)
  end

  def format_distance(km, _locale), do: format_metric_distance(km)

  defp format_imperial_distance(miles) when miles < 1.0, do: "#{Float.round(miles * 5280, 0)} ft"

  defp format_imperial_distance(miles), do: "#{Float.round(miles, 2)} mi"

  defp format_metric_distance(km) when km < 1.0, do: "#{Float.round(km * 1000, 0)} m"
  defp format_metric_distance(km), do: "#{Float.round(km, 2)} km"

  def calculate_course(lat1, lon1, lat2, lon2) do
    # Convert Decimal to float if needed.
    lat1 = to_float(lat1)
    lon1 = to_float(lon1)
    lat2 = to_float(lat2)
    lon2 = to_float(lon2)

    dlon = :math.pi() / 180 * (lon2 - lon1)
    lat1_rad = :math.pi() / 180 * lat1
    lat2_rad = :math.pi() / 180 * lat2

    y = :math.sin(dlon) * :math.cos(lat2_rad)
    x = :math.cos(lat1_rad) * :math.sin(lat2_rad) - :math.sin(lat1_rad) * :math.cos(lat2_rad) * :math.cos(dlon)

    normalize_bearing(:math.atan2(y, x) * 180 / :math.pi())
  end

  # Convert atan2 output from ±180 to 0-360.
  defp normalize_bearing(b) when b < 0, do: b + 360
  defp normalize_bearing(b), do: b

  defp get_heard_by_stations(callsign, locale) do
    # Get packets from the last month where this callsign was heard on RF
    one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day)

    # Query to find stations that heard this callsign directly
    # In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly
    query = """
    WITH digipeater_location AS (
      SELECT DISTINCT ON (sender)
        sender as digipeater,
        location::geography as dig_loc
      FROM packets
      WHERE sender IN (
        SELECT DISTINCT
          substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
        FROM packets
        WHERE sender = $1
          AND received_at >= $2
          AND path IS NOT NULL
          AND path != ''
          AND path !~ '^TCPIP'
          AND path !~ ',TCPIP'
          AND path ~ '[A-Z0-9]+-?[0-9]*\\*'
      )
        AND location IS NOT NULL
      ORDER BY sender, received_at DESC
    ),
    parsed_paths AS (
      SELECT
        id,
        sender,
        path,
        received_at,
        lat,
        lon,
        location,
        -- Extract the first digipeater from the path
        CASE
          WHEN path ~ '[A-Z0-9]+-?[0-9]*\\*' THEN
            substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
          ELSE NULL
        END as first_digipeater
      FROM packets
      WHERE sender = $1
        AND received_at >= $2
        AND path IS NOT NULL
        AND path != ''
        AND path !~ '^TCPIP'
        AND path !~ ',TCPIP'
    ),
    longest_paths AS (
      SELECT DISTINCT ON (pp.first_digipeater)
        pp.first_digipeater,
        pp.id as longest_path_packet_id
      FROM parsed_paths pp
      WHERE pp.first_digipeater IS NOT NULL
        AND pp.lat IS NOT NULL
        AND pp.lon IS NOT NULL
      ORDER BY pp.first_digipeater,
        ST_Distance(
          pp.location::geography,
          COALESCE(
            (SELECT dl.dig_loc FROM digipeater_location dl WHERE dl.digipeater = pp.first_digipeater),
            pp.location::geography
          )
        ) DESC NULLS LAST
    ),
    digipeater_stats AS (
      SELECT
        pp.first_digipeater as digipeater,
        MIN(pp.received_at) as first_heard,
        MAX(pp.received_at) as last_heard,
        COUNT(*) as packet_count,
        lp.longest_path_packet_id
      FROM parsed_paths pp
      LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater
      WHERE pp.first_digipeater IS NOT NULL
      GROUP BY pp.first_digipeater, lp.longest_path_packet_id
    )
    SELECT
      ds.digipeater,
      ds.first_heard,
      ds.last_heard,
      ds.packet_count,
      p.received_at as longest_path_time,
      CASE
        WHEN p.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN
          ST_Distance(p.location::geography, dl.dig_loc) / 1000.0
        ELSE NULL
      END as longest_distance_km
    FROM digipeater_stats ds
    LEFT JOIN packets p ON p.id = ds.longest_path_packet_id
    LEFT JOIN digipeater_location dl ON dl.digipeater = ds.digipeater
    ORDER BY ds.last_heard DESC
    LIMIT 50
    """

    case Repo.query(query, [callsign, one_month_ago]) do
      {:ok, result} ->
        result.rows
        |> Enum.map(fn row -> map_digipeater_row(row, locale) end)
        |> Enum.reject(&is_nil/1)

      {:error, error} ->
        Logger.error("Error in get_heard_by_stations: #{inspect(error)}")
        []
    end
  end

  defp get_stations_heard_by(callsign, locale) do
    # Get packets from the last month where this callsign heard other stations on RF
    one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day)

    # Optimized query to find stations that were heard directly by this callsign
    # Eliminates correlated subqueries and reduces redundant operations
    query = """
    WITH digipeater_location AS (
      -- Get the most recent location for the digipeater (cached once)
      SELECT location::geography as dig_loc
      FROM packets
      WHERE sender = $1
        AND location IS NOT NULL
      ORDER BY received_at DESC
      LIMIT 1
    ),
    parsed_paths AS (
      SELECT
        id,
        sender,
        path,
        received_at,
        lat,
        lon,
        location,
        -- Extract the first digipeater from the path more efficiently
        CASE
          WHEN path ~ ($1 || '\\*') THEN
            substring(path from ($1 || '\\*'))
          ELSE NULL
        END as first_digipeater
      FROM packets
      WHERE path ~ ($1 || '\\*')
        AND received_at >= $2
        AND path IS NOT NULL
        AND path != ''
        AND path !~ '^TCPIP'
        AND path !~ ',TCPIP'
        AND lat IS NOT NULL
        AND lon IS NOT NULL
    ),
    station_stats AS (
      SELECT
        sender as station,
        MIN(received_at) as first_heard,
        MAX(received_at) as last_heard,
        COUNT(*) as packet_count
      FROM parsed_paths
      WHERE first_digipeater = $1
      GROUP BY sender
    ),
    longest_paths AS (
      -- Find the longest path packet for each station in a single pass
      SELECT DISTINCT ON (pp.sender)
        pp.sender,
        pp.id as longest_path_packet_id,
        pp.received_at as longest_path_time,
        CASE
          WHEN pp.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN
            ST_Distance(pp.location::geography, dl.dig_loc) / 1000.0
          ELSE NULL
        END as longest_distance_km
      FROM parsed_paths pp
      CROSS JOIN digipeater_location dl
      WHERE pp.first_digipeater = $1
      ORDER BY pp.sender,
               ST_Distance(pp.location::geography, dl.dig_loc) DESC NULLS LAST,
               pp.received_at DESC
    )
    SELECT
      ss.station,
      ss.first_heard,
      ss.last_heard,
      ss.packet_count,
      lp.longest_path_time,
      lp.longest_distance_km
    FROM station_stats ss
    LEFT JOIN longest_paths lp ON lp.sender = ss.station
    ORDER BY ss.last_heard DESC
    LIMIT 50
    """

    case Repo.query(query, [callsign, one_month_ago]) do
      {:ok, result} ->
        result.rows
        |> Enum.map(fn row -> map_station_heard_row(row, locale) end)
        |> Enum.reject(&is_nil/1)

      {:error, error} ->
        Logger.error("Error in get_stations_heard_by: #{inspect(error)}")
        []
    end
  end

  @doc """
  Renders an APRS symbol as HTML for overlay symbols that need proper overlay character display.
  """
  def render_symbol_html(packet, size \\ 32) do
    if packet do
      {symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet)

      if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do
        sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code)
        overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id)

        style =
          "position: relative; width: #{size}px; height: #{size}px; background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file}); background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position}; background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size}; background-repeat: no-repeat, no-repeat; image-rendering: pixelated; display: inline-block; vertical-align: middle; margin-bottom: -6px;"

        escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()

        raw("<div style=\"#{escaped_style}\"></div>")
      else
        style = AprsSymbol.render_style(symbol_table_id, symbol_code, size)
        escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()

        raw("<div style=\"#{escaped_style}\"></div>")
      end
    else
      raw("")
    end
  end

  defp decode_aprs_path(path) when is_binary(path) and path != "" do
    path_elements = String.split(path, ",")

    decoded_elements = Enum.map(path_elements, &decode_path_element/1)

    # Filter out nil results and join with explanations
    decoded_elements
    |> Enum.reject(&is_nil/1)
    |> Enum.join(" → ")
  end

  defp decode_aprs_path(_), do: nil

  @doc """
  Parses the APRS path and creates linked callsigns where appropriate.
  Returns a list of tuples: {:text, string} or {:link, callsign, display_text}
  """
  def parse_path_with_links(path) when is_binary(path) and path != "" do
    path
    |> String.split(",")
    |> Enum.map(&parse_path_element_with_link/1)
  end

  def parse_path_with_links(_), do: []

  defp parse_path_element_with_link(element) do
    element = String.trim(element)
    classify_path_element(linkable_callsign?(element), element)
  end

  # Only real callsigns (not digipeater aliases or q-constructs) become links.
  defp linkable_callsign?(element) do
    Regex.match?(~r/^[A-Z0-9]{1,6}(-\d{1,2})?(\*)?$/, element) and
      not path_alias?(element)
  end

  defp path_alias?(element) do
    String.starts_with?(element, "WIDE") or
      String.starts_with?(element, "TRACE") or
      String.starts_with?(element, "RELAY") or
      String.starts_with?(element, "TCPIP") or
      String.starts_with?(element, "q")
  end

  # Strip the trailing `*` from the canonical callsign while preserving it
  # in the display string so the UI still shows "was-heard-via" markers.
  defp classify_path_element(true, element) do
    callsign = String.replace(element, "*", "")
    {:link, callsign, display_for_element(String.ends_with?(element, "*"), callsign)}
  end

  defp classify_path_element(false, element), do: {:text, element, element}

  defp display_for_element(true, callsign), do: "#{callsign}*"
  defp display_for_element(false, callsign), do: callsign

  defp decode_path_element(element) do
    element = String.trim(element)

    cond do
      # Check Q-constructs map first (most common)
      Map.has_key?(@q_constructs, element) ->
        @q_constructs[element]

      # WIDE digipeaters
      String.starts_with?(element, "WIDE") ->
        decode_wide_element(element)

      # TRACE digipeaters
      String.starts_with?(element, "TRACE") ->
        decode_trace_element(element)

      # RELAY digipeaters
      String.starts_with?(element, "RELAY") ->
        decode_relay_element(element)

      # TCPIP digipeaters
      String.starts_with?(element, "TCPIP") ->
        decode_tcpip_element(element)

      # Generic callsign with SSID (likely a digipeater)
      Regex.match?(~r/^[A-Z0-9]+-\d+$/, element) ->
        gettext("%{element} (Digipeater)", element: element)

      # Generic callsign without SSID
      Regex.match?(~r/^[A-Z0-9]+$/, element) ->
        gettext("%{element} (Station)", element: element)

      # Default case
      true ->
        gettext("%{element} (Unknown)", element: element)
    end
  end

  defp decode_wide_element("WIDE1-1"), do: gettext("WIDE1-1 (Wide area digipeater, 1 hop)")
  defp decode_wide_element("WIDE2-1"), do: gettext("WIDE2-1 (Wide area digipeater, 2 hops)")
  defp decode_wide_element("WIDE3-1"), do: gettext("WIDE3-1 (Wide area digipeater, 3 hops)")
  defp decode_wide_element("WIDE4-1"), do: gettext("WIDE4-1 (Wide area digipeater, 4 hops)")
  defp decode_wide_element("WIDE5-1"), do: gettext("WIDE5-1 (Wide area digipeater, 5 hops)")
  defp decode_wide_element("WIDE6-1"), do: gettext("WIDE6-1 (Wide area digipeater, 6 hops)")
  defp decode_wide_element("WIDE7-1"), do: gettext("WIDE7-1 (WIDE area digipeater, 7 hops)")
  defp decode_wide_element("WIDE1-2"), do: gettext("WIDE1-2 (Wide area digipeater, 1 hop, 2nd attempt)")
  defp decode_wide_element("WIDE2-2"), do: gettext("WIDE2-2 (Wide area digipeater, 2 hops, 2nd attempt)")
  defp decode_wide_element(element), do: gettext("WIDE digipeater (%{element})", element: element)

  defp decode_trace_element("TRACE1-1"), do: gettext("TRACE1-1 (Trace digipeater, 1 hop)")
  defp decode_trace_element("TRACE2-1"), do: gettext("TRACE2-1 (Trace digipeater, 2 hops)")
  defp decode_trace_element("TRACE3-1"), do: gettext("TRACE3-1 (Trace digipeater, 3 hops)")
  defp decode_trace_element("TRACE4-1"), do: gettext("TRACE4-1 (Trace digipeater, 4 hops)")
  defp decode_trace_element("TRACE5-1"), do: gettext("TRACE5-1 (Trace digipeater, 5 hops)")
  defp decode_trace_element("TRACE6-1"), do: gettext("TRACE6-1 (Trace digipeater, 6 hops)")
  defp decode_trace_element("TRACE7-1"), do: gettext("TRACE7-1 (Trace digipeater, 7 hops)")
  defp decode_trace_element(element), do: gettext("TRACE digipeater (%{element})", element: element)

  defp decode_relay_element("RELAY"), do: gettext("RELAY (Relay digipeater)")
  defp decode_relay_element("RELAY-1"), do: gettext("RELAY-1 (Relay digipeater, 1 hop)")
  defp decode_relay_element("RELAY-2"), do: gettext("RELAY-2 (Relay digipeater, 2 hops)")
  defp decode_relay_element(element), do: gettext("RELAY digipeater (%{element})", element: element)

  defp decode_tcpip_element("TCPIP"), do: gettext("TCPIP (Internet gateway)")
  defp decode_tcpip_element("TCPIP*"), do: gettext("TCPIP* (Internet gateway, no forward)")
  defp decode_tcpip_element(element), do: gettext("TCPIP gateway (%{element})", element: element)

  defp map_digipeater_row(row, locale) do
    case row do
      [digipeater, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] ->
        %{
          digipeater: digipeater || "",
          first_heard: first_heard,
          last_heard: last_heard,
          packet_count: packet_count || 0,
          longest_distance_km: longest_distance_km,
          longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)),
          longest_path_time: longest_path_time
        }

      _ ->
        nil
    end
  end

  defp map_station_heard_row(row, locale) do
    case row do
      [station, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] ->
        %{
          station: station || "",
          first_heard: first_heard,
          last_heard: last_heard,
          packet_count: packet_count || 0,
          longest_distance_km: longest_distance_km,
          longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)),
          longest_path_time: longest_path_time
        }

      _ ->
        nil
    end
  end
end
