defmodule Microwaveprop.Ionosphere do
  @moduledoc """
  Context for ionospheric observations (GIRO ionosonde data). Used by
  the propagation scorer for VHF sporadic-E and HF MUF predictions.
  """

  import Ecto.Query

  alias Microwaveprop.Ionosphere.Observation
  alias Microwaveprop.Repo

  @doc """
  Upsert a list of parsed `GiroClient` observations for a station.
  Conflicts on `(station_code, valid_time)` replace every column except
  the primary key and inserted_at. Returns `{:ok, count}`.
  """
  @spec upsert_observations(String.t(), [map()]) :: {:ok, non_neg_integer()}
  def upsert_observations(_station_code, []), do: {:ok, 0}

  def upsert_observations(station_code, observations) when is_list(observations) do
    now = DateTime.truncate(DateTime.utc_now(), :second)

    rows =
      Enum.map(observations, fn obs ->
        obs
        |> Map.put(:station_code, station_code)
        |> Map.put(:inserted_at, now)
        |> Map.put(:updated_at, now)
      end)

    {count, _} =
      Repo.insert_all(Observation, rows,
        on_conflict: {:replace_all_except, [:id, :inserted_at]},
        conflict_target: [:station_code, :valid_time]
      )

    {:ok, count}
  end

  @doc """
  Returns the most recent `Observation` row for a station, or nil.
  """
  @spec latest_observation(String.t()) :: Observation.t() | nil
  def latest_observation(station_code) do
    Repo.one(
      from o in Observation,
        where: o.station_code == ^station_code,
        order_by: [desc: o.valid_time],
        limit: 1
    )
  end

  # Station coordinates for the nearest-lookup. Must stay in sync with
  # `IonosphereFetchWorker.stations/0` — these are the stations we're
  # actively polling. Kept here (rather than imported) to avoid a
  # context → worker dependency.
  @stations [
    %{code: "MHJ45", lat: 42.6, lon: -71.5},
    %{code: "AL945", lat: 45.1, lon: -83.6}
  ]

  @default_max_age_seconds 2 * 3600

  @doc """
  Look up the nearest polled ionosonde station's latest observation for
  the given lat/lon.

  Options:
  - `:max_age_seconds` — reject observations older than this (default 2h).
    Returns `{:error, :stale}` if the nearest station has no fresh data.

  Returns `{:ok, observation}`, `{:error, :stale}`, or `{:error, :no_data}`.
  """
  @spec nearest_foes(number(), number(), keyword()) ::
          {:ok, Observation.t()} | {:error, :stale | :no_data}
  def nearest_foes(lat, lon, opts \\ []) do
    max_age = Keyword.get(opts, :max_age_seconds, @default_max_age_seconds)
    nearest_station = nearest_station_to(lat, lon)

    case latest_observation(nearest_station.code) do
      nil ->
        {:error, :no_data}

      %Observation{valid_time: vt} = obs ->
        age = DateTime.diff(DateTime.utc_now(), vt, :second)

        if age <= max_age do
          {:ok, obs}
        else
          {:error, :stale}
        end
    end
  end

  defp nearest_station_to(lat, lon) do
    Enum.min_by(@stations, fn s -> haversine_km(lat, lon, s.lat, s.lon) end)
  end

  # Standard haversine distance in km. Good enough for station-pick
  # ranking — we're not computing hop geometry here.
  defp haversine_km(lat1, lon1, lat2, lon2) do
    r = 6371.0
    dlat = :math.pi() * (lat2 - lat1) / 180
    dlon = :math.pi() * (lon2 - lon1) / 180
    lat1_rad = :math.pi() * lat1 / 180
    lat2_rad = :math.pi() * lat2 / 180

    a =
      :math.sin(dlat / 2) * :math.sin(dlat / 2) +
        :math.cos(lat1_rad) * :math.cos(lat2_rad) *
          :math.sin(dlon / 2) * :math.sin(dlon / 2)

    c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
    r * c
  end
end
