defmodule Aprsme.Packets.QueryBuilder do
  @moduledoc """
  Query builder functions for composing Packet queries.
  Provides reusable, composable functions for common query patterns.
  """

  import Ecto.Query

  alias Aprsme.Packet

  @doc """
  Filters query by time range. Supports start_time, end_time, and hours_back options.

  ## Options
    * `:start_time` - DateTime to filter packets after
    * `:end_time` - DateTime to filter packets before
    * `:hours_back` - Alternative to start_time, filters packets from X hours ago
  """
  @spec with_time_range(Ecto.Query.t(), map() | keyword()) :: Ecto.Query.t()
  def with_time_range(query, opts) when is_list(opts) do
    with_time_range(query, Map.new(opts))
  end

  def with_time_range(query, opts) when is_map(opts) do
    query
    |> maybe_filter_start_time(opts[:start_time] || opts["start_time"])
    |> maybe_filter_end_time(opts[:end_time] || opts["end_time"])
    |> maybe_filter_hours_back(opts[:hours_back] || opts["hours_back"])
  end

  @doc """
  Filters query to only include packets with position data.
  """
  @spec with_position(Ecto.Query.t()) :: Ecto.Query.t()
  def with_position(query) do
    from p in query, where: p.has_position == true
  end

  @doc """
  Filters query by exact callsign match (case-insensitive).
  Uses the functional index on upper(sender) for performance.
  """
  @spec for_callsign(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
  def for_callsign(query, callsign) when is_binary(callsign) do
    normalized = String.upcase(String.trim(callsign))
    from p in query, where: fragment("upper(?)", p.sender) == ^normalized
  end

  @doc """
  Filters query by base callsign (without SSID).
  """
  @spec for_base_callsign(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
  def for_base_callsign(query, base_callsign) when is_binary(base_callsign) do
    from p in query, where: p.base_callsign == ^base_callsign
  end

  @doc """
  Orders query by received_at descending (most recent first).
  """
  @spec recent_first(Ecto.Query.t()) :: Ecto.Query.t()
  def recent_first(query) do
    from p in query, order_by: [desc: p.received_at]
  end

  @doc """
  Orders query by received_at ascending (oldest first).
  """
  @spec chronological(Ecto.Query.t()) :: Ecto.Query.t()
  def chronological(query) do
    from p in query, order_by: [asc: p.received_at]
  end

  @doc """
  Filters query to only weather packets.
  Uses the indexed `has_weather` boolean column.
  """
  @spec weather_only(Ecto.Query.t()) :: Ecto.Query.t()
  def weather_only(query) do
    from p in query, where: p.has_weather == true
  end

  @doc """
  Applies pagination with limit and optional offset.
  """
  @spec paginate(Ecto.Query.t(), integer(), integer()) :: Ecto.Query.t()
  def paginate(query, limit, offset \\ 0) when is_integer(limit) and is_integer(offset) do
    from p in query, limit: ^limit, offset: ^offset
  end

  @doc """
  Adds PostGIS coordinate extraction to select clause.
  """
  @spec with_coordinates(Ecto.Query.t()) :: Ecto.Query.t()
  def with_coordinates(query) do
    from p in query,
      select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
  end

  @doc """
  Selects only the fields needed for map display as a plain map.
  Returns ~22 columns instead of all 73, reducing I/O significantly.
  """
  @spec select_map_fields(Ecto.Query.t()) :: Ecto.Query.t()
  def select_map_fields(query) do
    from p in query,
      select: %{
        id: p.id,
        sender: p.sender,
        object_name: p.object_name,
        item_name: p.item_name,
        lat: fragment("?::float8", p.lat),
        lon: fragment("?::float8", p.lon),
        received_at: p.received_at,
        symbol_table_id: p.symbol_table_id,
        symbol_code: p.symbol_code,
        comment: p.comment,
        path: p.path,
        temperature: p.temperature,
        humidity: p.humidity,
        wind_direction: p.wind_direction,
        wind_speed: p.wind_speed,
        wind_gust: p.wind_gust,
        pressure: p.pressure,
        rain_1h: p.rain_1h,
        rain_24h: p.rain_24h,
        rain_since_midnight: p.rain_since_midnight,
        snow: p.snow,
        luminosity: fragment("(?->>'luminosity')::integer", p.data)
      }
  end

  @doc """
  Filters by region if provided in options.
  """
  @spec maybe_filter_region(Ecto.Query.t(), map() | keyword()) :: Ecto.Query.t()
  def maybe_filter_region(query, opts) when is_list(opts) do
    maybe_filter_region(query, Map.new(opts))
  end

  def maybe_filter_region(query, %{region: region}) when not is_nil(region) do
    from p in query, where: p.region == ^region
  end

  def maybe_filter_region(query, _), do: query

  @doc """
  Filters by map bounds using bounding box coordinates.

  ## Example
      
      within_bounds(query, [-74.0, 40.0, -73.0, 41.0])
      # [west, south, east, north]
  """
  @spec within_bounds(Ecto.Query.t(), list(number())) :: Ecto.Query.t()
  def within_bounds(query, [west, south, east, north])
      when is_number(west) and is_number(south) and is_number(east) and is_number(north) do
    # Use ST_MakeEnvelope with && operator to leverage GiST spatial index on location column
    if west > east do
      # Handle antimeridian crossing with two envelopes: [west, 180] and [-180, east]
      from p in query,
        where: p.has_position == true,
        where:
          fragment("? && ST_MakeEnvelope(?, ?, 180, ?, 4326)", p.location, ^west, ^south, ^north) or
            fragment("? && ST_MakeEnvelope(-180, ?, ?, ?, 4326)", p.location, ^south, ^east, ^north)
    else
      from p in query,
        where: p.has_position == true,
        where: fragment("? && ST_MakeEnvelope(?, ?, ?, ?, 4326)", p.location, ^west, ^south, ^east, ^north)
    end
  end

  def within_bounds(query, _), do: query

  @doc """
  Common query composition for recent position packets.

  ## Options
    * `:limit` - Number of packets to return (default: 100)
    * `:start_time` - Filter packets after this time
    * `:end_time` - Filter packets before this time
    * `:region` - Filter by region
  """
  @spec recent_position_packets(map() | keyword()) :: Ecto.Query.t()
  def recent_position_packets(opts \\ %{}) do
    limit = opts[:limit] || opts["limit"] || 100

    from(p in Packet)
    |> with_position()
    |> with_time_range(opts)
    |> maybe_filter_region(opts)
    |> recent_first()
    |> paginate(limit)
    |> with_coordinates()
  end

  @doc """
  Common query composition for callsign packet history.

  ## Options
    * `:limit` - Number of packets to return (default: 100)
    * `:start_time` - Filter packets after this time
    * `:end_time` - Filter packets before this time
  """
  @spec callsign_history(String.t(), map() | keyword()) :: Ecto.Query.t()
  def callsign_history(callsign, opts \\ %{}) do
    limit = opts[:limit] || opts["limit"] || 100

    from(p in Packet)
    |> for_callsign(callsign)
    |> with_time_range(opts)
    |> recent_first()
    |> paginate(limit)
  end

  @doc """
  Common query composition for weather packets.

  ## Options
    * `:callsign` - Filter by specific callsign
    * `:limit` - Number of packets to return (default: 100)
    * `:start_time` - Filter packets after this time
    * `:end_time` - Filter packets before this time
  """
  @spec weather_packets(map() | keyword()) :: Ecto.Query.t()
  def weather_packets(opts \\ %{}) do
    limit = opts[:limit] || opts["limit"] || 100
    callsign = opts[:callsign] || opts["callsign"]

    query =
      from(p in Packet)
      |> weather_only()
      |> with_time_range(opts)
      |> recent_first()
      |> paginate(limit)

    if callsign do
      for_callsign(query, callsign)
    else
      query
    end
  end

  # Private helper functions

  defp maybe_filter_start_time(query, nil), do: query

  defp maybe_filter_start_time(query, start_time) do
    from p in query, where: p.received_at >= ^start_time
  end

  defp maybe_filter_end_time(query, nil), do: query

  defp maybe_filter_end_time(query, end_time) do
    from p in query, where: p.received_at <= ^end_time
  end

  defp maybe_filter_hours_back(query, nil), do: query

  defp maybe_filter_hours_back(query, hours) when is_number(hours) do
    start_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
    from p in query, where: p.received_at >= ^start_time
  end
end
