defmodule Microwaveprop.Propagation.MoonEphemeris do
  @moduledoc """
  Approximate moon-position calculation for EME classification.

  Accuracy target: ±1° — enough to decide "is the moon above the horizon
  for both stations right now?" which is the EME feasibility test. We
  use Jean Meeus's low-precision algorithm (Astronomical Algorithms,
  chapter 47), good to ~10' over historical amateur contact timestamps.

  No external ephemeris table — pure math so it works for any date.
  """

  @deg_to_rad :math.pi() / 180.0
  @rad_to_deg 180.0 / :math.pi()

  @doc """
  Returns the moon's altitude above the horizon in degrees for a given
  observer lat/lon and UTC timestamp. Negative means the moon is below
  the horizon (not reachable).
  """
  @spec altitude_deg(number(), number(), DateTime.t()) :: float()
  def altitude_deg(lat, lon, %DateTime{} = ts) do
    {ra, dec} = moon_ra_dec(ts)
    gst = greenwich_sidereal_hours(ts)
    # Local hour angle in degrees
    lst_deg = :math.fmod(gst * 15.0 + lon, 360.0)
    hour_angle = lst_deg - ra * 15.0

    lat_r = lat * @deg_to_rad
    dec_r = dec * @deg_to_rad
    ha_r = hour_angle * @deg_to_rad

    sin_alt =
      :math.sin(lat_r) * :math.sin(dec_r) +
        :math.cos(lat_r) * :math.cos(dec_r) * :math.cos(ha_r)

    sin_alt |> max(-1.0) |> min(1.0) |> :math.asin() |> Kernel.*(@rad_to_deg)
  end

  @doc """
  `true` if the moon is above the horizon at the given observer + time.
  """
  @spec above_horizon?(number(), number(), DateTime.t()) :: boolean()
  def above_horizon?(lat, lon, %DateTime{} = ts), do: altitude_deg(lat, lon, ts) > 0.0

  # — private math helpers below —

  # Returns {right ascension (hours), declination (deg)} of the moon.
  defp moon_ra_dec(%DateTime{} = ts) do
    jd = julian_day(ts)
    t = (jd - 2_451_545.0) / 36_525.0

    # Mean orbital elements (Meeus §47, degrees).
    l_prime = 218.3164477 + 481_267.88123421 * t
    d = 297.8501921 + 445_267.1114034 * t
    m = 357.5291092 + 35_999.0502909 * t
    m_prime = 134.9633964 + 477_198.8675055 * t
    f = 93.2720950 + 483_202.0175233 * t

    # Longitude correction (leading term only — 6.289°).
    lambda =
      l_prime +
        6.289 * sin_deg(m_prime) -
        1.274 * sin_deg(2 * d - m_prime) +
        0.658 * sin_deg(2 * d) -
        0.186 * sin_deg(m)

    # Latitude (β) leading term.
    beta = 5.128 * sin_deg(f)

    # Obliquity of the ecliptic.
    eps = 23.439 - 0.0000004 * (jd - 2_451_545.0)

    # Convert ecliptic (λ, β) → equatorial (α, δ).
    lam_r = lambda * @deg_to_rad
    beta_r = beta * @deg_to_rad
    eps_r = eps * @deg_to_rad

    sin_alpha = :math.sin(lam_r) * :math.cos(eps_r) - :math.tan(beta_r) * :math.sin(eps_r)
    cos_alpha = :math.cos(lam_r)
    alpha_r = :math.atan2(sin_alpha, cos_alpha)

    sin_delta =
      :math.sin(beta_r) * :math.cos(eps_r) +
        :math.cos(beta_r) * :math.sin(eps_r) * :math.sin(lam_r)

    delta_r = :math.asin(sin_delta)

    ra_hours = :math.fmod(alpha_r * @rad_to_deg / 15.0 + 48.0, 24.0)
    dec_deg = delta_r * @rad_to_deg
    {ra_hours, dec_deg}
  end

  defp julian_day(%DateTime{year: y, month: m, day: d, hour: h, minute: mi, second: s}) do
    {y2, m2} =
      if m <= 2 do
        {y - 1, m + 12}
      else
        {y, m}
      end

    a = div(y2, 100)
    b = 2 - a + div(a, 4)

    jd_day =
      Float.floor(365.25 * (y2 + 4716)) +
        Float.floor(30.6001 * (m2 + 1)) +
        d + b - 1524.5

    frac = (h + mi / 60 + s / 3600) / 24.0
    jd_day + frac
  end

  defp greenwich_sidereal_hours(%DateTime{} = ts) do
    jd = julian_day(ts)
    t = (jd - 2_451_545.0) / 36_525.0

    # Mean sidereal time at Greenwich, in hours.
    gmst_sec =
      67_310.54841 +
        (876_600 * 3600 + 8_640_184.812866) * t +
        0.093104 * t * t -
        6.2e-6 * t * t * t

    hours = gmst_sec / 3600.0
    :math.fmod(:math.fmod(hours, 24.0) + 24.0, 24.0)
  end

  defp sin_deg(deg), do: :math.sin(deg * @deg_to_rad)
end
