defmodule Aprsme.Packets.PreparedQueries do
  @moduledoc """
  High-performance queries using prepared statements and efficient patterns.
  These queries are designed for frequently executed operations.
  """

  import Ecto.Query

  alias Aprsme.Packet
  alias Aprsme.Repo

  @doc """
  Get the latest packet for a callsign using a prepared statement.
  This is one of the most frequently called queries.
  Searches by sender, object_name, or item_name.

  Bounded by the configured `:packet_retention_days` so PostgreSQL can prune
  unrelated daily partitions. Data older than retention is already cleaned up
  by `Aprsme.Workers.PacketCleanupWorker`, so this bound does not exclude any
  packets that would otherwise exist.
  """
  @spec get_latest_packet_for_callsign(String.t()) :: Packet.t() | nil
  def get_latest_packet_for_callsign(callsign) when is_binary(callsign) do
    normalized = String.upcase(String.trim(callsign))
    cutoff = retention_cutoff()

    Repo.one(
      from(p in Packet,
        where: p.received_at >= ^cutoff,
        where:
          fragment("upper(?)", p.sender) == ^normalized or
            fragment("upper(?)", p.object_name) == ^normalized or
            fragment("upper(?)", p.item_name) == ^normalized,
        order_by: [
          desc: fragment("CASE WHEN ? IS NULL THEN 0 ELSE 1 END", p.location),
          desc: p.received_at
        ],
        limit: 1,
        select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
      )
    )
  end

  defp retention_cutoff do
    retention_days = Application.get_env(:aprsme, :packet_retention_days, 7)
    DateTime.add(DateTime.utc_now(), -retention_days * 86_400, :second)
  end

  @doc """
  Get the latest full packet for each callsign in a single query.
  Returns a list of `Packet` structs with lat/lon populated.
  Uses DISTINCT ON to get only the most recent packet per callsign.
  """
  @spec get_latest_packets_for_callsigns(list(String.t())) :: [Packet.t()]
  def get_latest_packets_for_callsigns([]), do: []

  def get_latest_packets_for_callsigns(callsigns) when is_list(callsigns) do
    normalized = Enum.map(callsigns, &String.upcase(String.trim(&1)))
    limit = length(callsigns)

    Repo.all(
      from(p in Packet,
        where: fragment("upper(?)", p.sender) in ^normalized,
        distinct: fragment("upper(?)", p.sender),
        order_by: [asc: fragment("upper(?)", p.sender), desc: p.received_at],
        limit: ^limit,
        select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
      )
    )
  end

  @doc """
  Get latest positions for multiple callsigns in a single query.
  Returns a list of `%{callsign: String.t(), lat: float(), lng: float()}` maps.
  Uses DISTINCT ON to get only the most recent packet per callsign.
  """
  @spec get_latest_positions_for_callsigns(list(String.t())) :: [map()]
  def get_latest_positions_for_callsigns([]), do: []

  def get_latest_positions_for_callsigns(callsigns) when is_list(callsigns) do
    normalized = Enum.map(callsigns, &String.upcase(String.trim(&1)))
    limit = length(callsigns)

    Repo.all(
      from(p in Packet,
        where: fragment("upper(?)", p.sender) in ^normalized,
        where: not is_nil(p.location),
        distinct: fragment("upper(?)", p.sender),
        order_by: [asc: fragment("upper(?)", p.sender), desc: p.received_at],
        limit: ^limit,
        select: %{callsign: p.sender, lat: fragment("ST_Y(?)", p.location), lng: fragment("ST_X(?)", p.location)}
      )
    )
  end

  @doc """
  Get recent packets within bounds using prepared statement.
  """
  @spec get_recent_packets_in_bounds(list(), integer()) :: [Packet.t()]
  def get_recent_packets_in_bounds([north, south, east, west] = _bounds, hours_back \\ 1) do
    time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)

    Repo.all(
      from(p in Packet,
        where: p.has_position == true,
        where: p.received_at >= ^time_ago,
        # Use the && operator with ST_MakeEnvelope so PostGIS can hit the GIST
        # index on `location`. ST_Y / ST_X in the WHERE clause force a seq scan.
        where: fragment("? && ST_MakeEnvelope(?, ?, ?, ?, 4326)", p.location, ^west, ^south, ^east, ^north),
        order_by: [desc: p.received_at],
        limit: 500,
        select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
      )
    )
  end

  @doc """
  Check if callsign has weather packets using prepared statement.
  """
  @spec has_weather_packets?(String.t()) :: boolean()
  def has_weather_packets?(callsign) when is_binary(callsign) do
    normalized = String.upcase(String.trim(callsign))

    query =
      from(p in Packet,
        where: fragment("upper(?)", p.sender) == ^normalized,
        where:
          not is_nil(p.temperature) or
            not is_nil(p.humidity) or
            not is_nil(p.pressure) or
            not is_nil(p.wind_speed),
        limit: 1,
        select: true
      )

    Repo.exists?(query)
  end

  @doc """
  Check which callsigns from a list have weather packets.
  Returns a MapSet of callsigns (uppercased) that have weather data.
  Single query replaces N individual has_weather_packets? calls.
  """
  @spec weather_callsigns(list(String.t())) :: MapSet.t(String.t())
  def weather_callsigns(callsigns) when is_list(callsigns) do
    if callsigns == [] do
      MapSet.new()
    else
      normalized = Enum.map(callsigns, &String.upcase(String.trim(&1)))

      from(p in Packet,
        where: fragment("upper(?)", p.sender) in ^normalized,
        where: p.has_weather == true,
        distinct: fragment("upper(?)", p.sender),
        select: fragment("upper(?)", p.sender)
      )
      |> Repo.all()
      |> MapSet.new()
    end
  end

  @doc """
  Get nearby stations using KNN (K-nearest neighbors) search.
  Uses the <-> operator for efficient spatial queries.

  Options:
    * `:limit` - max number of stations to return (default 10)
    * `:hours_back` - only consider packets received in the last N hours (default 1)
    * `:max_radius_km` - only consider stations within N kilometers of the point
      (default 500 km / ~310 mi). This bound lets PostgreSQL pre-filter via
      the GiST geography index before the DISTINCT ON sort, which is otherwise
      the hot spot in this query.
  """
  @spec get_nearby_stations_knn(float(), float(), String.t() | nil, map()) :: [map()]
  def get_nearby_stations_knn(lat, lon, exclude_callsign \\ nil, opts \\ %{}) do
    limit = Map.get(opts, :limit, 10)
    hours_back = Map.get(opts, :hours_back, 1)
    max_radius_meters = Map.get(opts, :max_radius_km, 500) * 1000
    cutoff_time = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)

    # Build point for KNN search
    point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}

    base_query =
      from(p in Packet,
        where: p.has_position == true,
        where: p.received_at >= ^cutoff_time,
        where: not is_nil(p.location),
        where: fragment("ST_DWithin(?::geography, ?::geography, ?)", p.location, ^point, ^max_radius_meters),
        distinct: p.base_callsign,
        order_by: [
          asc: p.base_callsign,
          desc: p.received_at
        ]
      )

    query =
      if exclude_callsign do
        from(p in base_query,
          where: p.sender != ^exclude_callsign
        )
      else
        base_query
      end

    # Subquery to get most recent packet per callsign, then order by distance
    subquery =
      from(p in subquery(query),
        order_by: fragment("? <-> ?", p.location, ^point),
        limit: ^limit,
        select: %{
          callsign: p.sender,
          base_callsign: p.base_callsign,
          lat: fragment("ST_Y(?)", p.location),
          lon: fragment("ST_X(?)", p.location),
          distance: fragment("ST_Distance(?::geography, ?::geography)", p.location, ^point),
          received_at: p.received_at,
          symbol_table_id: p.symbol_table_id,
          symbol_code: p.symbol_code,
          comment: p.comment
        }
      )

    Repo.all(subquery)
  end

  @doc """
  Get packet count for an area using prepared statement.
  """
  @spec get_packet_count_in_area(list(), integer()) :: non_neg_integer()
  def get_packet_count_in_area([north, south, east, west], hours_back \\ 24) do
    time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)

    query =
      from(p in Packet,
        where: p.has_position == true,
        where: p.received_at >= ^time_ago,
        where: fragment("? && ST_MakeEnvelope(?, ?, ?, ?, 4326)", p.location, ^west, ^south, ^east, ^north),
        select: count(p.id)
      )

    Repo.one(query) || 0
  end

  @doc """
  Get nearby weather stations within a radius.

  Returns weather stations within the specified radius (in miles) that have reported
  weather data within the time window. Results are ordered by distance (closest first)
  and deduplicated by base_callsign to avoid duplicate SSIDs.

  ## Parameters

    * `lat` - Latitude of center point
    * `lon` - Longitude of center point
    * `radius_miles` - Search radius in miles
    * `opts` - Options keyword list
      * `:hours` - Time window in hours (default: 6)
      * `:limit` - Maximum number of results (default: 50)

  ## Returns

  List of maps with fields:
    * `callsign` - Station callsign with SSID
    * `base_callsign` - Base callsign without SSID
    * `lat` - Latitude
    * `lon` - Longitude
    * `distance_miles` - Distance from center point in miles
    * `temperature` - Temperature in Fahrenheit (may be nil)
    * `humidity` - Humidity percentage (may be nil)
    * `pressure` - Atmospheric pressure in hPa (may be nil)
    * `wind_speed` - Wind speed in MPH (may be nil)
    * `wind_direction` - Wind direction in degrees (may be nil)
    * `wind_gust` - Wind gust in MPH (may be nil)
    * `rain_1h` - Rain in last hour in inches (may be nil)
    * `rain_24h` - Rain in last 24 hours in inches (may be nil)
    * `rain_since_midnight` - Rain since midnight in inches (may be nil)
    * `symbol_table_id` - APRS symbol table ID
    * `symbol_code` - APRS symbol code
    * `comment` - Station comment
    * `received_at` - Time packet was received
  """
  @spec get_nearby_weather_stations(float(), float(), float(), keyword()) :: [map()]
  def get_nearby_weather_stations(lat, lon, radius_miles, opts \\ []) do
    hours = Keyword.get(opts, :hours, 6)
    limit = Keyword.get(opts, :limit, 50)

    # Convert miles to meters for PostGIS (1 mile = 1609.34 meters)
    radius_meters = radius_miles * 1609.34

    cutoff_time =
      DateTime.utc_now()
      |> DateTime.truncate(:second)
      |> DateTime.add(-hours * 3600, :second)

    # Build point for spatial query
    point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}

    # Query: Find weather stations within radius and time window
    # Use DISTINCT ON base_callsign to deduplicate SSIDs (gets most recent)
    query =
      from(p in Packet,
        where: p.has_weather == true,
        where: p.has_position == true,
        where: p.received_at >= ^cutoff_time,
        where:
          fragment(
            "ST_DWithin(?::geography, ?::geography, ?)",
            p.location,
            ^point,
            ^radius_meters
          ),
        distinct: p.base_callsign,
        order_by: [asc: p.base_callsign, desc: p.received_at]
      )

    # Subquery to get most recent per base_callsign, then order by distance
    subquery =
      from(p in subquery(query),
        order_by: fragment("ST_Distance(?::geography, ?::geography)", p.location, ^point),
        limit: ^limit,
        select: %{
          callsign: p.sender,
          base_callsign: p.base_callsign,
          lat: fragment("ST_Y(?)", p.location),
          lon: fragment("ST_X(?)", p.location),
          distance_miles:
            fragment(
              "ST_Distance(?::geography, ?::geography) / 1609.34",
              p.location,
              ^point
            ),
          temperature: p.temperature,
          humidity: p.humidity,
          pressure: p.pressure,
          wind_speed: p.wind_speed,
          wind_direction: p.wind_direction,
          wind_gust: p.wind_gust,
          rain_1h: p.rain_1h,
          rain_24h: p.rain_24h,
          rain_since_midnight: p.rain_since_midnight,
          symbol_table_id: p.symbol_table_id,
          symbol_code: p.symbol_code,
          comment: p.comment,
          received_at: p.received_at
        }
      )

    Repo.all(subquery)
  end
end
