defmodule Microwaveprop.Workers.ContactPositionBackfillWorker do
  @moduledoc """
  Hourly safety net that fills in `pos1` / `pos2` / `distance_km` for contacts
  whose grids resolve but whose positions are null.

  New contacts always have positions because `Radio.create_contact/1` refuses
  to insert without resolvable grids. This worker exists for rows that arrive
  via direct DB writes (bulk imports, manual fixes) which bypass that path.
  """
  use Oban.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]

  import Ecto.Query

  alias Microwaveprop.Radio
  alias Microwaveprop.Radio.Contact
  alias Microwaveprop.Radio.Maidenhead
  alias Microwaveprop.Repo

  require Logger

  @default_limit 500

  @impl Oban.Worker
  def perform(%Oban.Job{args: args}) do
    limit = Map.get(args, "limit", @default_limit)

    candidates =
      Contact
      |> where([c], (is_nil(c.pos1) and not is_nil(c.grid1)) or (is_nil(c.pos2) and not is_nil(c.grid2)))
      |> limit(^limit)
      |> Repo.all()

    count =
      Enum.count(candidates, fn contact ->
        updates = compute_updates(contact)

        if updates == [] do
          false
        else
          {n, _} =
            Contact
            |> where(id: ^contact.id)
            |> Repo.update_all(set: updates)

          n == 1
        end
      end)

    if count > 0 do
      Logger.info("ContactPositionBackfill: populated positions for #{count} contacts")
    end

    {:ok, %{updated: count}}
  end

  defp compute_updates(contact) do
    pos1 = maybe_pos(contact.pos1, contact.grid1)
    pos2 = maybe_pos(contact.pos2, contact.grid2)

    []
    |> add_pos_change(:pos1, contact.pos1, pos1)
    |> add_pos_change(:pos2, contact.pos2, pos2)
    |> add_distance_change(contact, pos1, pos2)
    |> maybe_touch_updated_at()
  end

  defp maybe_pos(existing, _grid) when not is_nil(existing), do: existing
  defp maybe_pos(_nil, nil), do: nil

  defp maybe_pos(_nil, grid) do
    case Maidenhead.to_latlon(grid) do
      {:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon}
      :error -> nil
    end
  end

  defp add_pos_change(updates, _field, existing, _new) when not is_nil(existing), do: updates
  defp add_pos_change(updates, _field, _existing, nil), do: updates
  defp add_pos_change(updates, field, _nil, new), do: [{field, new} | updates]

  # Always overwrite distance_km when both positions resolve — the candidate
  # query only selects contacts with at least one nil pos, so any previously
  # stored distance is tied to a stale grid/pos pair.
  defp add_distance_change(updates, _contact, nil, _pos2), do: updates
  defp add_distance_change(updates, _contact, _pos1, nil), do: updates

  defp add_distance_change(updates, _contact, pos1, pos2) do
    distance =
      pos1["lat"]
      |> Radio.haversine_km(pos1["lon"], pos2["lat"], pos2["lon"])
      |> round()
      |> Decimal.new()

    [{:distance_km, distance} | updates]
  end

  defp maybe_touch_updated_at([]), do: []

  defp maybe_touch_updated_at(updates) do
    [{:updated_at, DateTime.truncate(DateTime.utc_now(), :second)} | updates]
  end
end
