defmodule Aprsme.Packets do
  @moduledoc """
  The Packets context.
  """

  @behaviour Aprsme.PacketsBehaviour

  import Ecto.Query, warn: false

  alias Aprs.Types.MicE
  alias Aprsme.BadPacket
  alias Aprsme.Packet
  alias Aprsme.Packets.PreparedQueries
  alias Aprsme.Packets.QueryBuilder
  alias Aprsme.Repo

  require Logger

  @doc """
  Stores a packet in the database.

  ## Parameters
    * `packet_data` - Map containing packet data to be stored
  """
  @spec store_packet(map()) :: {:ok, struct()} | {:error, :validation_error | :storage_exception}
  def store_packet(packet_data) do
    with :ok <- validate_sender(packet_data),
         {:ok, sanitized_data} <- sanitize_packet_data(packet_data),
         {:ok, packet_attrs} <- build_packet_attrs(sanitized_data),
         {:ok, packet} <- insert_packet(packet_attrs, packet_data) do
      {:ok, packet}
    else
      {:error, {:validation_error, message}} ->
        Logger.error("Failed to store packet for #{inspect(packet_data[:sender])}: #{message}")
        store_bad_packet(packet_data, %{message: message, type: "ValidationError"})
        {:error, :validation_error}

      {:error, {:storage_exception, exception}} ->
        Logger.error("Storage exception for #{inspect(packet_data[:sender])}: #{inspect(exception)}")
        store_bad_packet(packet_data, %{message: inspect(exception), type: "StorageException"})
        {:error, :storage_exception}

      {:error, reason} = error ->
        Logger.error("Failed to store packet for #{inspect(packet_data[:sender])}: #{inspect(reason)}")
        store_bad_packet(packet_data, reason)
        error
    end
  end

  defp validate_sender(packet_data) when is_map(packet_data) do
    sender = Map.get(packet_data, :sender) || Map.get(packet_data, "sender")

    if Aprsme.Callsign.valid?(sender) do
      :ok
    else
      {:error, {:validation_error, "sender: must be a safe APRS identifier"}}
    end
  end

  defp validate_sender(_packet_data), do: {:error, {:validation_error, "sender: must be a safe APRS identifier"}}

  # Pattern matching for sanitization
  defp sanitize_packet_data(packet_data) do
    {:ok, Aprsme.EncodingUtils.sanitize_packet(packet_data)}
  rescue
    error -> {:error, {:sanitization_failed, error}}
  end

  # Build packet attributes with pipeline
  defp build_packet_attrs(sanitized_data) do
    raw_packet = get_raw_packet(sanitized_data)

    attrs =
      sanitized_data
      |> Packet.extract_additional_data(raw_packet)
      |> normalize_packet_attrs()
      |> set_received_at()
      |> patch_lat_lon_from_data_extended()
      |> apply_position_data()
      |> normalize_ssid()
      |> apply_device_identifier(sanitized_data)

    {:ok, attrs}
  rescue
    error -> {:error, {:build_attrs_failed, error}}
  end

  # Pattern matching for raw packet extraction
  defp get_raw_packet(%{raw_packet: raw}) when is_binary(raw), do: raw
  defp get_raw_packet(%{"raw_packet" => raw}) when is_binary(raw), do: raw
  defp get_raw_packet(_), do: ""

  # Apply position data using pattern matching
  defp apply_position_data(attrs) do
    case extract_position(attrs) do
      {nil, nil} -> attrs
      {lat, lon} -> set_lat_lon(attrs, lat, lon)
    end
  end

  # Apply device identifier with pattern matching
  defp apply_device_identifier(attrs, original_data) do
    case Aprsme.DeviceParser.extract_device_identifier(original_data) do
      nil ->
        # Fallback to destination
        Map.put(attrs, :device_identifier, Map.get(attrs, :destination))

      device_id ->
        Map.put(attrs, :device_identifier, get_canonical_device_identifier(device_id))
    end
  end

  defp normalize_packet_attrs(packet_data) do
    # store_packet's rescue branch indexes packet_data[:sender], which only works
    # on plain maps — Packet structs would raise. So normalize_packet_attrs only
    # ever sees a plain map here.
    packet_data
    |> Aprsme.EncodingUtils.sanitize_packet()
    |> normalize_data_type()
  end

  defp normalize_data_type(attrs) do
    Aprsme.EncodingUtils.normalize_data_type(attrs)
  end

  defp set_received_at(attrs) do
    current_time = DateTime.truncate(DateTime.utc_now(), :microsecond)
    Map.put(attrs, :received_at, current_time)
  end

  # Pattern matching with guards for cleaner code
  defp patch_lat_lon_from_data_extended(%{data_extended: %{} = ext} = attrs) do
    ext_map = normalize_to_map(ext)

    with lat when not is_nil(lat) <- extract_lat_from_ext_map(ext_map),
         lon when not is_nil(lon) <- extract_lon_from_ext_map(ext_map),
         {:ok, lat_decimal} <- to_decimal_safe(lat),
         {:ok, lon_decimal} <- to_decimal_safe(lon) do
      attrs
      |> Map.put(:lat, lat_decimal)
      |> Map.put(:lon, lon_decimal)
    else
      _ -> attrs
    end
  end

  defp patch_lat_lon_from_data_extended(attrs), do: attrs

  # Helper to normalize struct or map
  defp normalize_to_map(%{__struct__: _} = struct), do: Map.from_struct(struct)
  defp normalize_to_map(map) when is_map(map), do: map

  # Callers guard with `when not is_nil(value)`, so we never see nil here.
  defp to_decimal_safe(value) do
    case Aprsme.EncodingUtils.to_decimal(value) do
      nil -> {:error, :conversion_failed}
      decimal -> {:ok, decimal}
    end
  end

  # Pattern matching for coordinate extraction
  defp extract_lat_from_ext_map(ext_map) do
    extract_coordinate(ext_map, :latitude)
  end

  defp extract_lon_from_ext_map(ext_map) do
    extract_coordinate(ext_map, :longitude)
  end

  # Callers always pass a map (patch_lat_lon_from_data_extended pattern-matches
  # %{data_extended: %{} = ext} before calling, then wraps via normalize_to_map).
  defp extract_coordinate(map, coord_type) when coord_type in [:latitude, :longitude] do
    coord_string = Atom.to_string(coord_type)

    find_coordinate_value(map, coord_type) || find_coordinate_value(map, coord_string)
  end

  # Pattern matching for coordinate access — normalizes atom/string keys and
  # nested/flat maps into a uniform lookup before extracting the value.
  defp find_coordinate_value(map, coord_type) do
    key = coord_key(coord_type)
    direct_get(map, key) || deep_get(map, [:position, key])
  end

  defp coord_key(k) when is_atom(k), do: Atom.to_string(k)
  defp coord_key(k), do: k

  defp direct_get(map, key) when is_map(map) do
    Map.get(map, key) || Map.get(map, String.to_existing_atom(key))
  rescue
    ArgumentError -> nil
  end

  defp direct_get(_, _), do: nil

  defp deep_get(map, []) when is_map(map), do: map

  defp deep_get(map, [head | tail]) when is_map(map) do
    case Map.get(map, head) || Map.get(map, String.to_existing_atom(head)) do
      nil -> nil
      inner when is_map(inner) -> deep_get(inner, tail)
      _ -> nil
    end
  rescue
    ArgumentError -> nil
  end

  defp deep_get(_, _), do: nil

  defp set_lat_lon(attrs, lat, lon) do
    # Optimize: avoid creating anonymous function on each call
    attrs
    |> Map.put(:lat, round_coordinate(lat))
    |> Map.put(:lon, round_coordinate(lon))
  end

  # extract_position runs values through EncodingUtils.to_float, so by the
  # time round_coordinate sees them they're either float or nil.
  defp round_coordinate(nil), do: nil
  defp round_coordinate(n) when is_float(n), do: Float.round(n, 6)

  # Pattern matching for SSID normalization
  defp normalize_ssid(%{ssid: nil} = attrs), do: attrs
  defp normalize_ssid(%{ssid: ssid} = attrs) when is_binary(ssid), do: attrs
  defp normalize_ssid(%{ssid: ssid} = attrs), do: Map.put(attrs, :ssid, to_string(ssid))
  defp normalize_ssid(attrs), do: attrs

  defp insert_packet(attrs, _packet_data) do
    # Ensure data_extended is properly sanitized before insertion
    attrs = sanitize_data_extended_attr(attrs)

    case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do
      {:ok, packet} ->
        {:ok, packet}

      {:error, changeset} ->
        error_message =
          Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end)

        {:error, {:validation_error, error_message}}
    end
  rescue
    exception ->
      {:error, {:storage_exception, exception}}
  end

  defp sanitize_data_extended_attr(attrs) do
    if attrs[:data_extended] do
      sanitized_extended = Aprsme.EncodingUtils.sanitize_data_extended(attrs[:data_extended])
      sanitized_extended = deep_sanitize_map(sanitized_extended)
      Map.put(attrs, :data_extended, sanitized_extended)
    else
      attrs
    end
  end

  defp deep_sanitize_map(data) when is_struct(data), do: data

  defp deep_sanitize_map(data) when is_map(data) do
    Enum.reduce(data, %{}, fn {k, v}, acc ->
      sanitized_value = if is_binary(v), do: Aprsme.EncodingUtils.sanitize_string(v), else: v
      Map.put(acc, k, sanitized_value)
    end)
  end

  defp deep_sanitize_map(data), do: data

  @doc """
  Stores a bad packet in the database.

  ## Parameters
    * `packet_data` - The original packet data that failed to parse
    * `error` - The error that occurred during parsing
  """
  @spec store_bad_packet(map() | String.t(), any()) ::
          {:ok, struct()} | {:error, Ecto.Changeset.t()}
  def store_bad_packet(packet_data, error) do
    %BadPacket{}
    |> BadPacket.changeset(%{
      raw_packet: raw_packet_string(packet_data),
      error_message: extract_error_message(error),
      error_type: extract_error_type(error),
      attempted_at: DateTime.utc_now()
    })
    |> Repo.insert()
  end

  defp raw_packet_string(data) when is_binary(data), do: Aprsme.EncodingUtils.sanitize_string(data)
  defp raw_packet_string(%{raw_packet: rp}), do: rp
  defp raw_packet_string(%{"raw_packet" => rp}), do: rp
  defp raw_packet_string(data), do: inspect(data)

  defp extract_error_type(%{type: type}), do: type
  defp extract_error_type(%{__struct__: struct}), do: struct |> to_string() |> String.replace("Elixir.", "")
  defp extract_error_type(_), do: "UnknownError"

  defp extract_error_message(%{message: message}), do: message
  defp extract_error_message(%{__struct__: _} = error), do: Exception.message(error)
  defp extract_error_message(error), do: inspect(error)

  # Extracts position data from packet, checking various possible locations
  defp extract_position(packet_data) do
    # Check for lat/lon at top level
    if not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) do
      {Aprsme.EncodingUtils.to_float(packet_data.lat), Aprsme.EncodingUtils.to_float(packet_data.lon)}
    else
      extract_position_from_data_extended(packet_data[:data_extended])
    end
  end

  defp extract_position_from_data_extended(nil), do: {nil, nil}

  defp extract_position_from_data_extended(data_extended) when is_map(data_extended) do
    if has_standard_position?(data_extended) do
      extract_standard_position(data_extended)
    else
      extract_position_from_data_extended_case(data_extended)
    end
  end

  defp extract_position_from_data_extended(_), do: {nil, nil}

  defp has_standard_position?(data_extended) when is_map(data_extended) and not is_struct(data_extended) do
    not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude])
  end

  defp has_standard_position?(_), do: false

  defp extract_standard_position(data_extended) when is_map(data_extended) and not is_struct(data_extended) do
    {Aprsme.EncodingUtils.to_float(data_extended[:latitude]), Aprsme.EncodingUtils.to_float(data_extended[:longitude])}
  end

  defp extract_standard_position(_), do: {nil, nil}

  defp extract_position_from_data_extended_case(data_extended) do
    case data_extended do
      %{__struct__: MicE} = mic_e ->
        extract_position_from_mic_e_struct(mic_e)

      %{__struct__: Aprs.Types.ParseError} ->
        # ParseError structs don't contain position data and don't implement Access behavior
        {nil, nil}

      _ ->
        extract_position_from_mic_e(data_extended)
    end
  end

  defp extract_position_from_mic_e_struct(%{__struct__: MicE} = mic_e) do
    # Use Access behavior for MicE struct which implements it
    lat = mic_e[:latitude]
    lon = mic_e[:longitude]

    if is_number(lat) and is_number(lon) do
      {lat, lon}
    else
      {nil, nil}
    end
  end

  defp extract_position_from_mic_e(%{
         lat_degrees: lat_deg,
         lat_minutes: lat_min,
         lat_direction: lat_dir,
         lon_degrees: lon_deg,
         lon_minutes: lon_min,
         lon_direction: lon_dir
       })
       when is_number(lat_deg) and is_number(lat_min) and is_number(lon_deg) and is_number(lon_min) do
    lat = apply_direction(lat_deg + lat_min / 60.0, lat_dir, :south)
    lon = apply_direction(lon_deg + lon_min / 60.0, lon_dir, :west)
    {lat, lon}
  end

  defp extract_position_from_mic_e(_data_extended), do: {nil, nil}

  defp apply_direction(value, direction, negative_direction) when direction == negative_direction, do: -value
  defp apply_direction(value, _direction, _negative_direction), do: value

  # Pattern matching for canonical device identifier lookup
  defp get_canonical_device_identifier(device_identifier) when is_binary(device_identifier) do
    case Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier) do
      %{identifier: canonical_id} when is_binary(canonical_id) -> canonical_id
      _ -> device_identifier
    end
  end

  defp get_canonical_device_identifier(device_identifier), do: to_string(device_identifier)

  defp maybe_filter_by_callsign(query, %{callsign: callsign}) when not is_nil(callsign) do
    QueryBuilder.for_callsign(query, callsign)
  end

  defp maybe_filter_by_callsign(query, _), do: query

  defp maybe_filter_by_bounds(query, bounds) when is_list(bounds) and length(bounds) == 4 do
    QueryBuilder.within_bounds(query, bounds)
  end

  defp maybe_filter_by_bounds(query, _), do: query

  @doc """
  Gets historical packet count for a map area.
  """
  @impl true
  @spec get_historical_packet_count(map()) :: non_neg_integer()
  def get_historical_packet_count(opts \\ %{}) do
    bounds = Map.get(opts, :bounds)

    query =
      from(p in Packet)
      |> QueryBuilder.with_position()
      |> QueryBuilder.with_time_range(opts)
      |> QueryBuilder.maybe_filter_region(opts)
      |> maybe_filter_by_callsign(opts)
      |> maybe_filter_by_bounds(bounds)
      |> select(count())

    case Repo.one(query) do
      nil -> 0
      count -> count
    end
  rescue
    _ -> 0
  end

  @doc """
  Gets recent packets for initial map load.
  This uses an efficient query pattern for the most common use case.

  Ordering is `received_at DESC, id DESC`. Pagination is keyset/cursor-based:

    * `:cursor` - Optional `%{received_at: DateTime.t(), id: Ecto.UUID.t()}` marking
      the last row from the previous page. When present, only rows strictly older
      than the cursor are returned. When absent, the first page is returned.
    * `:limit` - Maximum number of packets to return (default 200).

  Offset-based pagination is no longer supported; callers doing deep paging must
  thread `{received_at, id}` from the last returned packet back in as `:cursor`.
  Passing `offset: 0` (or omitting it) is still accepted and behaves as "first page".
  """
  @impl true
  @spec get_recent_packets(map()) :: [struct()]
  def get_recent_packets(opts \\ %{}) do
    opts
    |> build_recent_packets_query()
    |> QueryBuilder.with_coordinates()
    |> Repo.all()
  end

  @doc """
  Gets recent packets for map display with only the columns needed for rendering.
  Returns maps (not full Packet structs) to avoid loading all 73 columns.

  Pagination is cursor/keyset-based — see `get_recent_packets/1`.
  """
  @spec get_recent_packets_for_map(map()) :: [map()]
  def get_recent_packets_for_map(opts \\ %{}) do
    opts
    |> build_recent_packets_query()
    |> QueryBuilder.select_map_fields()
    |> Repo.all()
  end

  # Shared query construction for get_recent_packets/1 and get_recent_packets_for_map/1.
  # Applies time filter, position/callsign/bounds/region filters, cursor keyset,
  # and ordering/limit. The two public functions differ only in their select shape.
  defp build_recent_packets_query(opts) do
    hours_back = Map.get(opts, :hours_back, 24)
    time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
    limit = Map.get(opts, :limit, 200)

    callsign = opts[:callsign]
    normalized_callsign = if is_binary(callsign), do: String.trim(callsign), else: ""

    base_query =
      if normalized_callsign == "" do
        # For general map view, only show packets with positions
        from(p in Packet,
          where: p.has_position == true,
          where: p.received_at >= ^time_ago
        )
      else
        # When tracking a callsign, show all their packets regardless of position
        from(p in Packet,
          where: p.received_at >= ^time_ago
        )
      end

    bounds = Map.get(opts, :bounds)

    base_query
    |> QueryBuilder.maybe_filter_region(opts)
    |> maybe_filter_by_callsign(opts)
    |> maybe_filter_by_bounds(bounds)
    |> maybe_apply_cursor(Map.get(opts, :cursor))
    |> order_by([p], desc: p.received_at, desc: p.id)
    |> limit(^limit)
  end

  # Keyset pagination: only rows strictly older than the cursor in the
  # (received_at DESC, id DESC) ordering.
  defp maybe_apply_cursor(query, %{received_at: %DateTime{} = cursor_received_at, id: cursor_id})
       when is_binary(cursor_id) do
    from p in query,
      where:
        p.received_at < ^cursor_received_at or
          (p.received_at == ^cursor_received_at and p.id < ^cursor_id)
  end

  defp maybe_apply_cursor(query, _), do: query

  @doc """
  Gets the closest stations to a given point.
  Uses PostGIS spatial indexes for efficient querying.
  Returns only the most recent packet per callsign, ordered by distance.
  """
  @impl true
  @spec get_nearby_stations(float(), float(), String.t() | nil, map()) :: [struct()]
  def get_nearby_stations(lat, lon, exclude_callsign \\ nil, opts \\ %{}) do
    # PreparedQueries returns plain maps from a select clause; wrap each in a
    # minimal Packet struct so callers get the shape they expect.
    lat
    |> PreparedQueries.get_nearby_stations_knn(lon, exclude_callsign, opts)
    |> Enum.map(fn result ->
      %Packet{
        sender: result.callsign,
        base_callsign: result.base_callsign,
        lat: result.lat,
        lon: result.lon,
        received_at: result.received_at,
        symbol_table_id: result.symbol_table_id,
        symbol_code: result.symbol_code,
        comment: result.comment
      }
    end)
  end

  @doc """
  Gets weather packets for a specific callsign within a time range.
  This is optimized for weather queries by filtering at the database level.
  """
  @impl true
  @spec get_weather_packets(String.t(), DateTime.t(), DateTime.t(), map()) :: [struct()]
  def get_weather_packets(callsign, start_time, end_time, opts \\ %{}) do
    opts =
      Map.merge(opts, %{
        callsign: callsign,
        start_time: start_time,
        end_time: end_time,
        limit: Map.get(opts, :limit, 500)
      })

    opts
    |> QueryBuilder.weather_packets()
    |> QueryBuilder.with_coordinates()
    |> Repo.all()
  end

  # Filter for weather packets at the database level

  @doc """
  Gets the total count of stored packets in the database.
  Uses the exact retained packet counter maintained by packet DML and partition cleanup.
  """
  @spec get_total_packet_count() :: non_neg_integer()
  def get_total_packet_count do
    # Use the PostgreSQL function for instant count retrieval
    case Repo.query("SELECT get_packet_count()", []) do
      {:ok, %{rows: [[count]]}} when is_integer(count) ->
        count

      {:ok, %{rows: [[nil]]}} ->
        0

      {:error, reason} ->
        Logger.warning("Unable to read packet count: #{inspect(reason)}")
        0
    end
  rescue
    DBConnection.ConnectionError ->
      Logger.warning("Database connection error in get_total_packet_count, returning 0")
      0

    error ->
      Logger.error("Error getting total packet count: #{inspect(error)}")
      0
  end

  @doc """
  Gets the timestamp of the oldest stored packet in the database.
  Returns nil if no packets exist.
  """
  @spec get_oldest_packet_timestamp() :: DateTime.t() | nil
  def get_oldest_packet_timestamp do
    Repo.one(
      from p in Packet,
        select: min(p.received_at)
    )
  rescue
    _ -> nil
  end

  # Get packets from last hour only - used to initialize the map
  @spec get_last_hour_packets() :: [struct()]
  def get_last_hour_packets do
    %{hours_back: 1, limit: 500}
    |> QueryBuilder.recent_position_packets()
    |> QueryBuilder.chronological()
    |> Repo.all()
  rescue
    error ->
      Logger.error("Failed to get last hour packets: #{inspect(error)}")
      []
  end

  @doc """
  Gets the most recent packet for a callsign regardless of type or age.
  This is used for API endpoints that need the latest packet from a source.
  """
  @spec get_latest_packet_for_callsign(String.t()) :: struct() | nil
  def get_latest_packet_for_callsign(callsign) when is_binary(callsign) do
    # Use prepared statement for better performance
    PreparedQueries.get_latest_packet_for_callsign(callsign)
  end

  @doc """
  Gets latest positions for multiple callsigns in a single batch query.
  Returns a list of `%{callsign: String.t(), lat: float(), lng: float()}` maps.
  """
  @spec get_latest_positions_for_callsigns(list(String.t())) :: [map()]
  def get_latest_positions_for_callsigns(callsigns) when is_list(callsigns) do
    PreparedQueries.get_latest_positions_for_callsigns(callsigns)
  end

  @doc """
  Gets the latest full packet for each callsign in a single batch query.
  Returns a list of `Packet` structs with lat/lon populated.
  """
  @spec get_latest_packets_for_callsigns(list(String.t())) :: [Packet.t()]
  def get_latest_packets_for_callsigns(callsigns) when is_list(callsigns) do
    PreparedQueries.get_latest_packets_for_callsigns(callsigns)
  end

  @doc """
  Gets the most recent weather packet for a callsign.
  Looks for any packet containing weather data fields.
  """
  @spec get_latest_weather_packet(String.t()) :: struct() | nil
  def get_latest_weather_packet(callsign) when is_binary(callsign) do
    query =
      from p in Packet,
        where: p.sender == ^callsign,
        where: p.has_weather == true,
        order_by: [desc: p.received_at],
        limit: 1

    Repo.one(query)
  end

  @doc """
  Check if a callsign has any weather packets.
  """
  def has_weather_packets?(callsign) when is_binary(callsign) do
    # Use prepared statement for better performance
    PreparedQueries.has_weather_packets?(callsign)
  end

  @doc """
  Check which callsigns from a list have weather packets.
  Returns a MapSet of uppercased callsigns with weather data.
  """
  @spec weather_callsigns(list(String.t())) :: MapSet.t(String.t())
  def weather_callsigns(callsigns) when is_list(callsigns) do
    PreparedQueries.weather_callsigns(callsigns)
  end

  @doc """
  Gets other SSIDs for a given callsign's base callsign.
  Returns a list of maps with callsign, ssid, received_at, and packet info.
  Only returns SSIDs active within the last hour.
  """
  @spec get_other_ssids(String.t()) :: list(map())
  def get_other_ssids(callsign) when is_binary(callsign) do
    base_callsign = Aprsme.Callsign.extract_base(callsign)
    one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)

    query =
      from p in Packet,
        where: p.base_callsign == ^base_callsign,
        where: p.received_at >= ^one_hour_ago,
        where: p.sender != ^callsign,
        distinct: p.sender,
        order_by: [desc: p.received_at],
        limit: 10,
        select: %{
          sender: p.sender,
          ssid: p.ssid,
          received_at: p.received_at,
          id: p.id,
          symbol_table_id: p.symbol_table_id,
          symbol_code: p.symbol_code
        }

    query
    |> Repo.all()
    |> Enum.map(fn row ->
      packet = %Packet{
        id: row.id,
        sender: row.sender,
        ssid: row.ssid,
        received_at: row.received_at,
        symbol_table_id: row.symbol_table_id,
        symbol_code: row.symbol_code
      }

      %{
        callsign: row.sender,
        ssid: row.ssid,
        received_at: row.received_at,
        packet: packet
      }
    end)
  end
end
