defmodule AprsmeWeb.MobileChannel do
  @moduledoc """
  Channel for mobile clients to receive real-time APRS packets filtered by geographic bounds.

  ## Usage

  1. Connect to socket: wss://aprs.me/mobile/websocket
  2. Join channel: mobile:packets
  3. Subscribe to bounds: push "subscribe_bounds" with bounds payload
  4. Receive packets: listen for "packet" events
  5. Update bounds: push "update_bounds" with new bounds

  ## Messages

  ### Client -> Server

  - "subscribe_bounds" - Subscribe to packets within geographic bounds
    Payload: %{north: float, south: float, east: float, west: float}

  - "update_bounds" - Update subscription bounds (e.g., when user pans/zooms map)
    Payload: %{north: float, south: float, east: float, west: float}

  - "unsubscribe" - Stop receiving packets

  - "search_callsign" - Search for callsigns matching a pattern
    Payload: %{query: string, limit: integer (optional)}

  - "subscribe_callsign" - Subscribe to live updates for a callsign pattern
    Payload: %{callsign: string, hours_back: integer (optional)}

  - "unsubscribe_callsign" - Stop receiving updates for tracked callsign

  ### Server -> Client

  - "packet" - New APRS packet within subscribed bounds or matching tracked callsign
    Payload: %{
      id: string,
      callsign: string,
      lat: float,
      lng: float,
      timestamp: string (ISO 8601),
      symbol_table_id: string,
      symbol_code: string,
      comment: string (optional),
      altitude: float (optional),
      speed: float (optional),
      course: integer (optional),
      path: string (optional)
    }

  - "subscription_confirmed" - Bounds subscription successful
    Payload: %{bounds: map, message: string}
  """
  use AprsmeWeb, :channel

  import Ecto.Query

  alias Aprsme.ApiMetrics
  alias Aprsme.PlausibleAnalytics
  alias AprsmeWeb.Live.Shared.CoordinateUtils

  require Logger

  # Per-socket rate limits on the expensive operations. Streaming packet
  # delivery is unaffected — these only guard client-initiated queries.
  @expensive_op_limit 30
  @expensive_op_scale 60_000

  # Maximum number of spatial_packet messages buffered per socket before
  # oldest packets are dropped. Prevents a single slow client from consuming
  # unbounded memory.
  @max_packet_queue 500

  # Payload size limits
  @max_query_length 20
  @max_callsign_length 20

  # Maximum search results returned to mobile clients
  @max_search_results 200

  # Reject bounds viewports larger than this (square degrees); prevents
  # clients from subscribing to "global" feeds.
  @max_bounds_area_deg 1000

  @impl true
  def join("mobile:packets", _payload, socket) do
    Logger.info("Mobile client joined packets channel")
    {:ok, %{message: "Connected to APRS mobile channel"}, assign(socket, :packet_queue, [])}
  end

  @impl true
  def handle_in(
        "subscribe_bounds",
        %{"north" => north, "south" => south, "east" => east, "west" => west} = payload,
        socket
      ) do
    Logger.info("Mobile websocket received subscribe_bounds: #{inspect(payload)}")

    with :ok <- check_rate_limit(socket, "subscribe_bounds") do
      track_event(socket, "ws_subscribe_bounds")

      bounds = %{
        north: ensure_float(north),
        south: ensure_float(south),
        east: ensure_float(east),
        west: ensure_float(west)
      }

      # Validate bounds
      case validate_bounds(bounds) do
        :ok ->
          # Generate unique client ID
          client_id = "mobile_#{:erlang.phash2(self())}"

          {:ok, topic} = Aprsme.SpatialPubSub.register_viewport(client_id, bounds)
          :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, topic)

          # Store client info in socket
          socket =
            socket
            |> assign(:client_id, client_id)
            |> assign(:bounds, bounds)
            |> assign(:subscribed, true)

          Logger.info("Mobile client #{client_id} subscribed to bounds: #{inspect(bounds)}")

          # Load and send historical packets
          socket = load_historical_packets(socket, bounds, payload)

          {:reply, {:ok, %{bounds: bounds, message: "Subscribed to packet stream"}}, socket}

        {:error, reason} ->
          {:reply, {:error, %{message: reason}}, socket}
      end
    end
  end

  @impl true
  def handle_in(
        "update_bounds",
        %{"north" => north, "south" => south, "east" => east, "west" => west} = payload,
        socket
      ) do
    Logger.info("Mobile websocket received update_bounds: #{inspect(payload)}")

    bounds = %{
      north: ensure_float(north),
      south: ensure_float(south),
      east: ensure_float(east),
      west: ensure_float(west)
    }

    # Check if subscribed
    if Map.get(socket.assigns, :subscribed, false) do
      # Validate bounds
      case validate_bounds(bounds) do
        :ok ->
          track_event(socket, "ws_update_bounds")
          update_subscription_bounds(socket, bounds)

        {:error, reason} ->
          {:reply, {:error, %{message: reason}}, socket}
      end
    else
      {:reply, {:error, %{message: "Not subscribed. Call subscribe_bounds first."}}, socket}
    end
  end

  @impl true
  def handle_in("unsubscribe", payload, socket) do
    Logger.info("Mobile websocket received unsubscribe: #{inspect(payload)}")

    if socket.assigns[:subscribed] && socket.assigns[:bounds] do
      Aprsme.SpatialPubSub.unregister_client(socket.assigns.client_id)

      socket =
        socket
        |> assign(:subscribed, false)
        |> assign(:bounds, nil)

      Logger.info("Mobile client #{socket.assigns[:client_id]} unsubscribed from packet stream")

      {:reply, {:ok, %{message: "Unsubscribed from packet stream"}}, socket}
    else
      {:reply, {:ok, %{message: "Not subscribed"}}, socket}
    end
  end

  @impl true
  def handle_in("search_callsign", %{"query" => query} = payload, socket) do
    Logger.info("Mobile websocket received search_callsign: #{inspect(payload)}")

    with :ok <- check_rate_limit(socket, "search_callsign") do
      # Trim whitespace and control characters from query
      query = String.trim(query)

      if byte_size(query) > @max_query_length do
        emit_payload_rejected("search_callsign_query_too_long", %{length: byte_size(query)})
        {:reply, {:error, %{message: "Query too long (max #{@max_query_length} characters)"}}, socket}
      else
        track_event(socket, "ws_search_callsign")

        limit = payload |> Map.get("limit", 50) |> ensure_integer(50)
        limit = min(limit, @max_search_results)

        results = search_callsign(query, limit)

        {:reply, {:ok, %{results: results, count: length(results)}}, socket}
      end
    end
  end

  @impl true
  def handle_in("subscribe_callsign", %{"callsign" => callsign} = payload, socket) do
    Logger.info("Mobile websocket received subscribe_callsign: #{inspect(payload)}")

    with :ok <- check_rate_limit(socket, "subscribe_callsign") do
      # Normalize callsign - trim whitespace and convert to uppercase
      callsign = callsign |> String.trim() |> String.upcase()

      if byte_size(callsign) > @max_callsign_length do
        emit_payload_rejected("subscribe_callsign_too_long", %{length: byte_size(callsign)})
        {:reply, {:error, %{message: "Callsign too long (max #{@max_callsign_length} characters)"}}, socket}
      else
        track_event(socket, "ws_subscribe_callsign")

        hours_back = payload |> Map.get("hours_back", 24) |> ensure_integer(24)
        # Max 1 week
        hours_back = min(hours_back, 168)

        # Load historical packets for this callsign
        socket = load_callsign_history(socket, callsign, hours_back)

        socket = ensure_callsign_subscription(socket)

        # Store tracked callsign in socket
        socket = assign(socket, :tracked_callsign, callsign)

        {:reply, {:ok, %{callsign: callsign, message: "Subscribed to callsign updates"}}, socket}
      end
    end
  end

  @impl true
  def handle_in("unsubscribe_callsign", payload, socket) do
    Logger.info("Mobile websocket received unsubscribe_callsign: #{inspect(payload)}")

    if socket.assigns[:tracked_callsign] do
      callsign = socket.assigns.tracked_callsign

      socket =
        socket
        |> remove_callsign_subscription()
        |> assign(:tracked_callsign, nil)

      Logger.info("Mobile client #{socket.assigns[:client_id]} unsubscribed from callsign: #{callsign}")

      {:reply, {:ok, %{message: "Unsubscribed from callsign updates"}}, socket}
    else
      {:reply, {:ok, %{message: "Not tracking any callsign"}}, socket}
    end
  end

  @impl true
  def handle_info({:spatial_packet, packet}, socket) do
    if should_queue_packet?(socket, packet) do
      queue = Map.get(socket.assigns, :packet_queue, [])
      queue = [packet | queue]

      {queue, dropped} =
        if length(queue) > @max_packet_queue do
          [_dropped | kept] = Enum.reverse(queue)
          {Enum.reverse(kept), 1}
        else
          {queue, 0}
        end

      if dropped > 0 do
        :telemetry.execute(
          [:aprsme, :mobile, :buffer_overflow],
          %{dropped: dropped, queue_size: @max_packet_queue},
          %{}
        )
      end

      # Push immediately as single "packet" event (preserves existing protocol)
      packet_data = build_mobile_packet(packet)
      push(socket, "packet", packet_data)

      {:noreply, assign(socket, :packet_queue, queue)}
    else
      {:noreply, socket}
    end
  end

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

  defp should_queue_packet?(socket, packet) do
    if tracked_callsign = socket.assigns[:tracked_callsign] do
      packet_callsign = get_field(packet, :sender) || get_field(packet, :base_callsign) || ""
      callsign_matches?(packet_callsign, tracked_callsign)
    else
      # If not tracking a callsign, queue all packets (geographic filtering already applied)
      true
    end
  end

  defp emit_payload_rejected(reason, meta) do
    Logger.warning("Mobile channel payload rejected: #{reason}", meta)

    :telemetry.execute(
      [:aprsme, :payload, :rejected],
      %{count: 1},
      %{reason: reason}
    )
  end

  # Per-socket limiter for the expensive client-initiated handlers
  # (subscribe_bounds, subscribe_callsign, search_callsign). Returns :ok on
  # allow or a {:reply, ..., socket} tuple that propagates straight out of the
  # with block to the channel reply path on deny.
  defp check_rate_limit(socket, op) do
    key = "mobile_channel:#{op}:#{:erlang.phash2(self())}"

    case Aprsme.RateLimiter.hit(key, @expensive_op_scale, @expensive_op_limit) do
      {:allow, _count} ->
        :ok

      {:deny, retry_after} ->
        Logger.warning("Mobile channel rate-limited op=#{op} client=#{inspect(self())}")

        {:reply, {:error, %{message: "Rate limit exceeded; retry later", retry_after_ms: retry_after}}, socket}
    end
  end

  # Push packets to the client in moderately sized batches under the new
  # `packets` event. Keeps the per-packet `packet` event format intact for
  # streaming deliveries; bulk historical loads move through this path so
  # clients don't get hit with 5000 individual WebSocket frames.
  @packets_batch_size 100
  defp push_packets_batch(_socket, []), do: :ok

  defp push_packets_batch(socket, packets) do
    packets
    |> Stream.map(&build_mobile_packet/1)
    |> Stream.chunk_every(@packets_batch_size)
    |> Enum.each(fn chunk ->
      push(socket, "packets", %{packets: chunk, count: length(chunk)})
    end)
  end

  # Clean up on terminate
  @impl true
  def terminate(_reason, socket) do
    if socket.assigns[:subscribed] && socket.assigns[:bounds] do
      Aprsme.SpatialPubSub.unregister_client(socket.assigns.client_id)
    end

    if callsign_subscription_active?(socket) do
      Aprsme.SpatialPubSub.unregister_client(socket.assigns.callsign_client_id)
    end

    :ok
  end

  # Private functions

  defp validate_bounds(%{north: north, south: south, east: east, west: west}) do
    cond do
      not all_numeric?(north, south, east, west) ->
        {:error, "All bounds must be numeric"}

      not valid_latitudes?(north, south) ->
        {:error, "Latitude must be between -90 and 90"}

      not valid_longitudes?(east, west) ->
        {:error, "Longitude must be between -180 and 180"}

      north <= south ->
        {:error, "North must be greater than south"}

      bounds_area_too_large?(north, south, east, west) ->
        emit_payload_rejected("bounds_area_too_large", %{area: bounds_area(north, south, east, west)})
        {:error, "Viewport area too large (max #{@max_bounds_area_deg} sq degrees)"}

      true ->
        :ok
    end
  end

  defp bounds_area(north, south, east, west) do
    width = abs(east - west)
    height = abs(north - south)
    width * height
  end

  defp bounds_area_too_large?(north, south, east, west) do
    bounds_area(north, south, east, west) > @max_bounds_area_deg
  end

  defp all_numeric?(north, south, east, west) do
    is_number(north) and is_number(south) and is_number(east) and is_number(west)
  end

  defp valid_latitudes?(north, south) do
    north >= -90 and north <= 90 and south >= -90 and south <= 90
  end

  defp valid_longitudes?(east, west) do
    east >= -180 and east <= 180 and west >= -180 and west <= 180
  end

  defp ensure_float(value) when is_integer(value), do: value * 1.0
  defp ensure_float(value) when is_float(value), do: value

  defp ensure_float(value) when is_binary(value) do
    case Float.parse(String.trim(value)) do
      {float, ""} -> float
      _ -> nil
    end
  end

  defp ensure_float(_value), do: nil

  # Dispatch on wildcard presence. `*` is the user-facing wildcard; SQL LIKE
  # uses `%`. Without a wildcard, match callsigns that start with the query
  # so SSID searches like "K5ABC" match "K5ABC-9".
  defp build_search_pattern(query) do
    build_search_pattern_for(String.contains?(query, "*"), query)
  end

  defp build_search_pattern_for(true, query), do: String.replace(query, "*", "%")
  defp build_search_pattern_for(false, query), do: "#{query}%"

  defp ensure_integer(value, _default) when is_integer(value), do: value

  defp ensure_integer(value, default) when is_binary(value) do
    case Integer.parse(String.trim(value)) do
      {int, _} -> int
      :error -> default
    end
  end

  defp ensure_integer(_value, default), do: default

  defp build_mobile_packet(packet) do
    # Extract coordinates
    {lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet)

    # Build minimal packet data for mobile
    %{
      id: get_field(packet, :id),
      callsign: get_field(packet, :sender) || get_field(packet, :base_callsign),
      lat: to_float(lat),
      lng: to_float(lon),
      timestamp: packet |> get_field(:received_at) |> format_timestamp(),
      symbol_table_id: get_field(packet, :symbol_table_id, "/"),
      symbol_code: get_field(packet, :symbol_code, ">"),
      comment: get_field(packet, :comment),
      altitude: get_field(packet, :altitude),
      speed: get_field(packet, :speed),
      course: get_field(packet, :course),
      path: get_field(packet, :path)
    }
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Map.new()
  end

  defp get_field(packet, field, default \\ nil) when is_map(packet) do
    Map.get(packet, field) || Map.get(packet, to_string(field), default)
  end

  defp to_float(nil), do: nil
  defp to_float(value) when is_float(value), do: value
  defp to_float(value) when is_integer(value), do: value * 1.0

  defp to_float(value) when is_binary(value) do
    case Float.parse(value) do
      {float, _} -> float
      :error -> nil
    end
  end

  defp to_float(%Decimal{} = value), do: Decimal.to_float(value)
  defp to_float(_), do: nil

  defp format_timestamp(nil), do: nil

  defp format_timestamp(%DateTime{} = dt) do
    DateTime.to_iso8601(dt)
  end

  defp format_timestamp(%NaiveDateTime{} = ndt) do
    ndt
    |> DateTime.from_naive!("Etc/UTC")
    |> DateTime.to_iso8601()
  end

  defp format_timestamp(_), do: nil

  defp load_historical_packets(socket, bounds, payload) do
    # Get optional parameters from payload
    limit = payload |> Map.get("limit", 1000) |> ensure_integer(1000)
    hours_back = payload |> Map.get("hours_back", 1) |> ensure_integer(1)

    # Ensure reasonable limits
    limit = min(limit, 5000)
    hours_back = min(hours_back, 24)

    # Query historical packets within bounds
    bounds_list = [
      bounds.west,
      bounds.south,
      bounds.east,
      bounds.north
    ]

    params = %{
      bounds: bounds_list,
      limit: limit,
      hours_back: hours_back
    }

    Logger.debug("Mobile client loading historical packets with params: #{inspect(params)}")

    packets =
      try do
        Aprsme.Packets.get_recent_packets(params)
      rescue
        error ->
          Logger.error("Error loading historical packets for mobile client: #{inspect(error)}")
          []
      end

    Logger.info("Loaded #{length(packets)} historical packets for mobile client")

    # Send historical packets as batched "packets" events rather than one
    # "packet" frame per row — previously a single subscription could queue
    # thousands of WebSocket frames.
    push_packets_batch(socket, packets)

    socket
  end

  defp search_callsign(query, limit) do
    # Normalize query to uppercase
    query = String.upcase(query)

    Logger.info("Searching for callsign: #{query}")

    pattern = build_search_pattern(query)
    Logger.info("Search pattern: #{pattern}")

    # Query for matching callsigns - optimized version
    # Use a subquery to get only the most recent packet per sender
    results =
      try do
        subquery =
          from p in Aprsme.Packet,
            where: ilike(p.sender, ^pattern),
            distinct: p.sender,
            select: %{
              callsign: p.sender,
              base_callsign: p.base_callsign,
              last_seen: max(p.received_at),
              # Use first_value for lat/lon from most recent packet
              lat: fragment("(array_agg(? ORDER BY ? DESC))[1]", p.lat, p.received_at),
              lon: fragment("(array_agg(? ORDER BY ? DESC))[1]", p.lon, p.received_at)
            },
            group_by: [p.sender, p.base_callsign],
            order_by: [desc: max(p.received_at)],
            limit: ^limit

        results = Aprsme.Repo.all(subquery)
        Logger.info("Search returned #{length(results)} results: #{inspect(results)}")
        results
      rescue
        error ->
          Logger.error("Error searching callsigns: #{inspect(error)}")
          []
      end

    results
  end

  defp load_callsign_history(socket, callsign, hours_back) do
    # Build pattern for callsign matching (supports wildcards)
    pattern =
      if String.contains?(callsign, "*") do
        String.replace(callsign, "*", "%")
      else
        # Exact callsign match
        callsign
      end

    # Calculate time window
    since = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)

    # Query historical packets
    packets =
      try do
        Aprsme.Repo.all(
          from p in Aprsme.Packet,
            where: p.received_at >= ^since,
            where: ilike(p.sender, ^pattern) or ilike(p.base_callsign, ^pattern),
            order_by: [asc: p.received_at],
            limit: 1000
        )
      rescue
        error ->
          Logger.error("Error loading callsign history: #{inspect(error)}")
          []
      end

    Logger.info("Loaded #{length(packets)} historical packets for callsign #{callsign}")

    # Send batched "packets" events — see push_packets_batch/2.
    push_packets_batch(socket, packets)

    socket
  end

  @spec callsign_matches?(String.t(), String.t()) :: boolean()
  defp callsign_matches?(packet_callsign, tracked_callsign) do
    do_callsign_match(String.upcase(packet_callsign), String.upcase(tracked_callsign))
  end

  @spec do_callsign_match(String.t(), String.t()) :: boolean()
  defp do_callsign_match(callsign, callsign), do: true

  defp do_callsign_match(packet_callsign, "*" <> _ = tracked_callsign),
    do: String.starts_with?(packet_callsign, String.replace(tracked_callsign, "*", ""))

  defp do_callsign_match(packet_callsign, tracked_callsign) do
    case String.split(tracked_callsign, "*", parts: 2) do
      [prefix, _suffix] -> String.starts_with?(packet_callsign, prefix)
      [_] -> String.starts_with?(packet_callsign, tracked_callsign <> "-")
    end
  end

  defp update_subscription_bounds(socket, bounds) do
    # Unsubscribe from old bounds if they exist
    if socket.assigns[:bounds] do
      :ok = Aprsme.SpatialPubSub.update_viewport(socket.assigns.client_id, bounds)
    end

    socket = assign(socket, :bounds, bounds)

    Logger.debug("Mobile client #{socket.assigns.client_id} updated bounds: #{inspect(bounds)}")

    {:reply, {:ok, %{bounds: bounds, message: "Bounds updated"}}, socket}
  end

  defp ensure_callsign_subscription(socket) do
    if callsign_subscription_active?(socket) do
      socket
    else
      client_id = "mobile_callsign_#{:erlang.phash2(self())}"
      {:ok, topic} = Aprsme.SpatialPubSub.register_unbounded(client_id)
      :ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, topic)

      socket
      |> assign(:callsign_client_id, client_id)
      |> assign(:callsign_subscription_active, true)
    end
  end

  defp remove_callsign_subscription(socket) do
    if callsign_subscription_active?(socket) do
      Aprsme.SpatialPubSub.unregister_client(socket.assigns.callsign_client_id)

      socket
      |> assign(:callsign_client_id, nil)
      |> assign(:callsign_subscription_active, false)
    else
      socket
    end
  end

  defp callsign_subscription_active?(socket) do
    Map.get(socket.assigns, :callsign_subscription_active, false)
  end

  defp track_event(socket, event_name) do
    ip = Map.get(socket.assigns, :peer_ip, "unknown")
    ua = Map.get(socket.assigns, :user_agent, "unknown")

    PlausibleAnalytics.track(
      %{user_agent: ua, remote_ip: ip, request_path: "/mobile/websocket"},
      event_name
    )

    ApiMetrics.track(%{}, event_name)
  end
end
