defmodule Microwaveprop.Pskr.Client do
  @moduledoc """
  MQTT 3.1.1 client for the PSK Reporter live spot firehose.

  Connects to `mqtt.pskreporter.info:1883` over plain TCP (the
  broker doesn't expose TLS), subscribes to a curated set of
  topics, and forwards every PUBLISH payload to
  `Pskr.Aggregator.ingest/2`. Each topic anchors **both** ends on
  USA (DXCC 291) so we only ingest CONUS-to-CONUS paths — that's
  the only path geometry where both endpoints sit inside HRRR
  coverage. When HRDPS lights up we'll add a Canada-anchored
  topic alongside.

  ## No external MQTT library

  We talk to the broker with `:gen_tcp` and the codec in
  `Microwaveprop.Pskr.Mqtt`. emqtt's transitive `quicer` dep was a
  CMake/native-build mess in our slim Debian builder image; the
  alternative pure-Elixir client (tortoise311) bundles a
  gen_state_machine + retry/inflight stack we don't use because
  we're subscribe-only at QoS 0. Implementing the wire protocol
  inline is ~150 lines of binary parsing — less code than the
  dependency-management overhead of either choice.

  ## Cluster singleton

  All replicas start this GenServer; **only one across the cluster
  actually connects to MQTT**. We elect a leader via
  `:global.register_name/2` (atomic across the BEAM cluster, which
  libcluster gives us). The losing nodes stay in `:standby` mode:
  no socket, no DB writes, just an idle process watching for the
  leader's `nodedown` to re-run election.

  Handover loses at most one flush cycle (~60 s) of in-flight
  aggregates from the old leader's Aggregator — acceptable, since
  the upsert-on-conflict logic in `Pskr.Aggregator` merges any
  overlapping path-hours additively.

  ## Bands

  Subscribes to **VHF and up** by default (6m, 2m, 70cm, 23cm). HF
  spots are dominated by ionospheric propagation, which isn't what
  this project models. Microwave bands are listed too but spots
  there are vanishingly rare (FT8 activity is mostly 144 MHz /
  432 MHz / 1296 MHz).

  ## Off in dev/test

  `:microwaveprop, :pskr_mqtt_enabled` defaults to `false` in
  dev/test so `mix test` doesn't open an outbound connection. Prod
  flips it on via runtime.exs.
  """
  use GenServer

  alias Microwaveprop.Pskr.Aggregator
  alias Microwaveprop.Pskr.Mqtt

  require Logger

  @broker_host ~c"mqtt.pskreporter.info"
  @broker_port 1883
  @keepalive_seconds 60
  @keepalive_ms @keepalive_seconds * 1000

  # USA = 291 (continental). PSK Reporter splits Alaska (6) and
  # Hawaii (110) as separate DXCCs but neither is currently in the
  # HRRR domain; HRDPS will eventually add Canada (1).
  @us_dxcc 291

  @default_bands ~w(6m 2m 70cm 23cm 13cm 9cm 6cm 3cm 1.25cm)

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
  end

  @impl true
  def init(opts) do
    Process.flag(:trap_exit, true)
    # `:net_kernel.monitor_nodes/1` lands `{:nodeup, node}` /
    # `{:nodedown, node}` in our mailbox — that's what tells a
    # standby client the leader vanished and election should
    # re-run.
    :ok = :net_kernel.monitor_nodes(true)

    state = %{
      role: :standby,
      socket: nil,
      buffer: <<>>,
      bands: Keyword.get(opts, :bands, @default_bands),
      aggregator: Keyword.get(opts, :aggregator, Aggregator),
      client_id: Keyword.get(opts, :client_id, default_client_id()),
      received: 0,
      ping_timer: nil
    }

    {:ok, state, {:continue, :elect}}
  end

  @impl true
  def handle_continue(:elect, state) do
    case :global.register_name(__MODULE__, self()) do
      :yes ->
        Logger.info("Pskr.Client elected leader on #{node()} — connecting to MQTT")
        {:noreply, %{state | role: :active}, {:continue, :connect}}

      :no ->
        Logger.info("Pskr.Client standby on #{node()} (leader: #{inspect(:global.whereis_name(__MODULE__))})")
        {:noreply, state}
    end
  end

  def handle_continue(:connect, state) do
    case do_connect(state) do
      {:ok, socket, ping_timer} ->
        {:noreply, %{state | socket: socket, ping_timer: ping_timer}}

      {:error, reason} ->
        Logger.error("Pskr.Client connect failed: #{inspect(reason)} — retrying in 30s")
        Process.send_after(self(), :reconnect, 30_000)
        {:noreply, state}
    end
  end

  @impl true
  def handle_info(:reconnect, state), do: {:noreply, state, {:continue, :connect}}

  def handle_info(:ping, %{socket: socket} = state) when is_port(socket) do
    case :gen_tcp.send(socket, Mqtt.pingreq()) do
      :ok ->
        {:noreply, %{state | ping_timer: schedule_ping()}}

      {:error, reason} ->
        Logger.warning("Pskr.Client PINGREQ send failed: #{inspect(reason)}")
        {:noreply, drop_socket(state), {:continue, :connect}}
    end
  end

  def handle_info(:ping, state), do: {:noreply, state}

  # Standby clients re-run election on any cluster topology change.
  # Active leaders ignore — their registration stays valid until
  # *their* node disappears.
  def handle_info({event, _node}, %{role: :standby} = state) when event in [:nodeup, :nodedown] do
    {:noreply, state, {:continue, :elect}}
  end

  def handle_info({event, _node}, state) when event in [:nodeup, :nodedown], do: {:noreply, state}

  def handle_info({:tcp, socket, data}, %{socket: socket} = state) do
    :inet.setopts(socket, active: :once)
    {:noreply, process_buffer(%{state | buffer: state.buffer <> data})}
  end

  def handle_info({:tcp_closed, socket}, %{socket: socket} = state) do
    Logger.warning("Pskr.Client TCP closed (received #{state.received} so far) — reconnecting in 5s")
    Process.send_after(self(), :reconnect, 5_000)
    {:noreply, drop_socket(state)}
  end

  def handle_info({:tcp_error, socket, reason}, %{socket: socket} = state) do
    Logger.warning("Pskr.Client TCP error #{inspect(reason)} — reconnecting in 5s")
    Process.send_after(self(), :reconnect, 5_000)
    {:noreply, drop_socket(state)}
  end

  def handle_info(_other, state), do: {:noreply, state}

  @impl true
  def terminate(_reason, %{socket: socket}) when is_port(socket) do
    _ = :gen_tcp.send(socket, Mqtt.disconnect())
    _ = :gen_tcp.close(socket)
    :ok
  end

  def terminate(_reason, _state), do: :ok

  # ---------- Connection setup ----------

  defp do_connect(state) do
    # Open in passive mode for the synchronous CONNECT/CONNACK
    # handshake — `:gen_tcp.recv/3` only works on passive sockets
    # and returns `:einval` if the socket is in any active mode.
    # After CONNACK we flip to `active: :once` so PUBLISHes arrive
    # as `{:tcp, socket, data}` messages.
    #
    # `:inet` forces IPv4 resolution + an IPv4 socket. Without
    # this, BEAM can pick a broker AAAA record while our K8s pod
    # has IPv4-only egress.
    tcp_opts = [:binary, :inet, active: false, packet: :raw, keepalive: true]

    case :gen_tcp.connect(@broker_host, @broker_port, tcp_opts, 10_000) do
      {:ok, socket} -> handshake(socket, state)
      {:error, _} = err -> err
    end
  end

  defp handshake(socket, state) do
    with :ok <- :gen_tcp.send(socket, Mqtt.connect(state.client_id, @keepalive_seconds)),
         {:ok, :connected, rest} <- await_connack(socket, <<>>),
         :ok <- :gen_tcp.send(socket, subscribe_packet(state.bands)),
         :ok <- :inet.setopts(socket, active: :once) do
      Logger.info("Pskr.Client connected as #{state.client_id}, subscribed to #{length(state.bands)} bands")
      # `rest` holds anything the broker sent piggybacked after
      # CONNACK — feed it back into the buffer so we don't lose
      # the SUBACK or an early PUBLISH.
      send(self(), {:tcp, socket, rest})
      {:ok, socket, schedule_ping()}
    else
      {:error, _} = err ->
        # Close the connected-but-not-handshaked socket so we
        # don't leak ports across retries.
        _ = :gen_tcp.close(socket)
        err
    end
  end

  # CONNACK arrives synchronously right after CONNECT, so a small
  # blocking read here keeps the state machine simple. If we don't
  # get it within 10s, treat the connection as dead.
  defp await_connack(socket, buffer) do
    case Mqtt.parse(buffer) do
      {:ok, {:connack, 0, _}, rest} ->
        {:ok, :connected, rest}

      {:ok, {:connack, code, _}, _} ->
        {:error, {:connack_refused, code}}

      {:incomplete, buf} ->
        case :gen_tcp.recv(socket, 0, 10_000) do
          {:ok, chunk} -> await_connack(socket, buf <> chunk)
          {:error, _} = err -> err
        end

      {:error, _} = err ->
        err
    end
  end

  defp subscribe_packet(bands) do
    topics = Enum.map(bands, fn band -> {topic_for(band), 0} end)
    Mqtt.subscribe(1, topics)
  end

  # Topic order (from the broker's landing page):
  #   pskr/filter/v2/<band>/<mode>/<sc>/<rc>/<sl>/<rl>/<sa>/<ra>
  # Both DXCC slots pinned to USA so the broker drops US↔CA, US↔EU,
  # etc. before the spots ever reach us.
  defp topic_for(band), do: "pskr/filter/v2/#{band}/+/+/+/+/+/#{@us_dxcc}/#{@us_dxcc}"

  # ---------- Inbound dispatch ----------

  defp process_buffer(state) do
    case Mqtt.parse(state.buffer) do
      {:ok, {:publish, _topic, payload}, rest} ->
        Aggregator.ingest(state.aggregator, payload)
        process_buffer(%{state | buffer: rest, received: state.received + 1})

      {:ok, {:suback, _id, results}, rest} ->
        if Enum.any?(results, &(&1 == 0x80)) do
          Logger.warning("Pskr.Client SUBACK contains failures: #{inspect(results)}")
        end

        process_buffer(%{state | buffer: rest})

      {:ok, :pingresp, rest} ->
        process_buffer(%{state | buffer: rest})

      {:ok, _other, rest} ->
        process_buffer(%{state | buffer: rest})

      {:incomplete, buffer} ->
        %{state | buffer: buffer}

      {:error, reason} ->
        Logger.error("Pskr.Client parse error: #{inspect(reason)} — dropping connection")
        Process.send_after(self(), :reconnect, 1_000)
        drop_socket(state)
    end
  end

  defp drop_socket(state) do
    if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
    if is_port(state.socket), do: :gen_tcp.close(state.socket)
    %{state | socket: nil, buffer: <<>>, ping_timer: nil}
  end

  defp schedule_ping, do: Process.send_after(self(), :ping, @keepalive_ms)

  # ---------- Client ID ----------

  defp default_client_id do
    suffix = 4 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
    "microwaveprop-#{node_short()}-#{suffix}"
  end

  defp node_short do
    case System.get_env("POD_IP") do
      nil -> node() |> to_string() |> String.replace(["@", "."], "-")
      ip -> String.replace(ip, ".", "-")
    end
  end
end
