defmodule Microwaveprop.Workers.MechanismClassifyWorker do
  @moduledoc """
  Per-contact propagation-mechanism classification.

  Assembles the full input map for `MechanismClassifier`:

    * `user_declared_prop_mode` — from the contact's ADIF PROP_MODE
    * `duct_either_endpoint` — from the HRRR profile at either endpoint
    * `native_best_duct_ghz` — from the nearest `hrrr_native_profiles` row
    * `radar` — from the pre-computed `contact_common_volume_radar` row
    * `kp_index` — from the daily `solar_indices` row
    * `foes_mhz` — from the nearest `ionosonde_observations` row
    * `active_meteor_shower` — looked up from the static shower calendar

  Results land on the contact as `propagation_mechanism` (string),
  `propagation_mechanism_confidence` (enum), and
  `propagation_mechanism_status`.

  Unique on `contact_id` so the submit-time enqueue path and the
  backfill cron collapse to a single job per contact.
  """

  use Oban.Worker,
    queue: :mechanism,
    max_attempts: 3,
    unique: [
      period: :infinity,
      states: [:available, :scheduled, :executing, :retryable],
      keys: [:contact_id]
    ]

  import Ecto.Query

  alias Microwaveprop.Ionosphere.Observation, as: IonosondeObservation
  alias Microwaveprop.Propagation.MechanismClassifier
  alias Microwaveprop.Radio.Contact
  alias Microwaveprop.Radio.ContactCommonVolumeRadar
  alias Microwaveprop.Repo
  alias Microwaveprop.Weather.HrrrProfile

  require Logger

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do
    Microwaveprop.Instrument.span([:worker, :mechanism_classify], %{contact_id: contact_id}, fn ->
      case Repo.get(Contact, contact_id) do
        nil ->
          :ok

        %Contact{pos1: p1, pos2: p2} = contact when is_map(p1) and is_map(p2) ->
          classify_and_persist(contact)

        contact ->
          mark_status(contact, :unavailable, nil, nil)
          :ok
      end
    end)
  end

  defp classify_and_persist(%Contact{} = contact) do
    inputs = build_inputs(contact)
    %{mechanism: mechanism, confidence: confidence} = MechanismClassifier.classify(inputs)

    mark_status(contact, :complete, Atom.to_string(mechanism), confidence)
    :ok
  rescue
    e ->
      Logger.warning("MechanismClassifyWorker: failed for contact #{contact.id}: #{inspect(e)}")

      mark_status(contact, :failed, nil, nil)
      :ok
  end

  defp build_inputs(%Contact{} = contact) do
    %{
      band_mhz: Decimal.to_integer(contact.band),
      distance_km: distance_km(contact),
      qso_timestamp: contact.qso_timestamp,
      pos1: contact.pos1,
      pos2: contact.pos2,
      user_declared_prop_mode: contact.user_declared_prop_mode,
      radar: radar_for(contact),
      duct_either_endpoint: duct_at_either_endpoint?(contact),
      native_best_duct_ghz: native_duct_at_either_endpoint(contact),
      kp_index: kp_for(contact),
      foes_mhz: foes_for(contact),
      active_meteor_shower: active_shower_at(contact.qso_timestamp)
    }
  end

  defp distance_km(%Contact{distance_km: nil}), do: 0.0
  defp distance_km(%Contact{distance_km: d}), do: Decimal.to_float(d)

  defp radar_for(%Contact{id: id}) do
    case Repo.get_by(ContactCommonVolumeRadar, contact_id: id) do
      nil ->
        nil

      row ->
        %{
          max_dbz: row.max_dbz,
          heavy_rain_pixel_count: row.heavy_rain_pixel_count,
          coverage_pct: row.coverage_pct
        }
    end
  end

  defp duct_at_either_endpoint?(%Contact{pos1: p1, pos2: p2, qso_timestamp: ts}) do
    Enum.any?([p1, p2], fn pos ->
      case nearest_hrrr(pos, ts) do
        nil -> false
        %HrrrProfile{ducting_detected: true} -> true
        _ -> false
      end
    end)
  end

  defp native_duct_at_either_endpoint(%Contact{pos1: p1, pos2: p2, qso_timestamp: ts}) do
    [p1, p2]
    |> Enum.map(&nearest_native_duct(&1, ts))
    |> Enum.reject(&is_nil/1)
    |> case do
      [] -> nil
      values -> Enum.max(values)
    end
  end

  defp nearest_hrrr(pos, ts) do
    lat = pos["lat"]
    lon = pos["lon"]

    if lat && lon do
      Repo.one(
        from(h in HrrrProfile,
          where:
            h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and
              h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second),
          order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts),
          limit: 1
        )
      )
    end
  end

  defp nearest_native_duct(pos, ts) do
    lat = pos["lat"]
    lon = pos["lon"]

    if lat && lon do
      Repo.one(
        from(h in "hrrr_native_profiles",
          where:
            h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and
              h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second),
          order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts),
          limit: 1,
          select: h.best_duct_band_ghz
        )
      )
    end
  end

  defp kp_for(%Contact{qso_timestamp: ts}) do
    date = DateTime.to_date(ts)

    # solar_indices.kp_values is the daily array of 3-hour Kp readings.
    # Take the day's peak — that's the signal that matters for aurora.
    from(s in "solar_indices", where: s.date == ^date, select: s.kp_values, limit: 1)
    |> Repo.one()
    |> case do
      nil -> nil
      [] -> nil
      values when is_list(values) -> values |> Enum.reject(&is_nil/1) |> peak_or_nil()
    end
  end

  defp peak_or_nil([]), do: nil
  defp peak_or_nil(values), do: values |> Enum.max() |> round()

  # Nearest ionosonde observation within ±1h of QSO timestamp. We don't
  # restrict by location — foEs is spatially noisy but at least the
  # "nearest station in the Americas" will catch US-continental Es events.
  defp foes_for(%Contact{qso_timestamp: ts}) do
    Repo.one(
      from(io in IonosondeObservation,
        where: io.valid_time >= ^DateTime.add(ts, -3600, :second) and io.valid_time <= ^DateTime.add(ts, 3600, :second),
        where: not is_nil(io.fo_es_mhz),
        order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", io.valid_time, ^ts),
        limit: 1,
        select: io.fo_es_mhz
      )
    )
  end

  # Static meteor-shower calendar (peak month/day, approximate). Any
  # contact within ±3 days of a peak counts as "during the shower."
  # Peaks are stored as {month, day} so year substitution doesn't
  # poison year-boundary showers (Jan Quadrantids vs Dec Ursids).
  @showers [
    {{1, 4}, "Quadrantids"},
    {{4, 22}, "Lyrids"},
    {{5, 6}, "Eta Aquariids"},
    {{7, 30}, "Southern Delta Aquariids"},
    {{8, 12}, "Perseids"},
    {{10, 21}, "Orionids"},
    {{11, 17}, "Leonids"},
    {{12, 14}, "Geminids"},
    {{12, 22}, "Ursids"}
  ]

  @shower_window_days 3

  defp active_shower_at(%DateTime{} = ts) do
    date = DateTime.to_date(ts)

    Enum.find_value(@showers, fn {{month, day}, name} ->
      if shower_active?(date, month, day), do: name
    end)
  end

  # Returns true when `date` falls within `@shower_window_days` of the
  # shower peak, handling year-boundary showers (e.g. Ursids peak Dec 22
  # can match a QSO on Dec 25 in year N or, when the window widens,
  # early January in year N+1). We check the peak projected onto the
  # contact's year AND the adjacent years so the ±3-day window works
  # correctly across Dec 31 → Jan 1.
  defp shower_active?(%Date{year: year} = date, month, day) do
    Enum.any?([year - 1, year, year + 1], fn y ->
      case Date.new(y, month, day) do
        {:ok, peak} -> abs(Date.diff(peak, date)) <= @shower_window_days
        _ -> false
      end
    end)
  end

  defp mark_status(%Contact{} = contact, status, mechanism, confidence) do
    contact
    |> Ecto.Changeset.change(%{
      propagation_mechanism_status: status,
      propagation_mechanism: mechanism,
      propagation_mechanism_confidence: confidence
    })
    |> Repo.update!()
  end
end
