defmodule Microwaveprop.Moon do
  @moduledoc """
  Low-precision lunar ephemeris.

  Uses the Astronomical Almanac "Low-Precision Formulas for the Moon"
  (Section D.4) — 6 longitude terms, 4 latitude terms, and 4 parallax
  terms. Accurate to ~0.3° on position and ~200 km on distance, which
  is well inside the beamwidth of any amateur EME dish at every
  supported microwave band (1° beam at 10 GHz for a 1 m dish; wider
  below that).

  Exposes two coordinates:

  - `geocentric/1` — the Moon's right ascension, declination, and
    distance from Earth's centre at a UTC instant.
  - `observer_position/3` — topocentric azimuth / elevation /
    slant range for an observer at `(lat, lon)` degrees.

  Both are pure functions of UTC plus observer coordinates; no side
  effects, no DB access. Callers drive them on a timer to refresh
  antenna aim points.
  """

  @earth_radius_km 6378.14

  @doc """
  Julian Day for a Gregorian `DateTime`. Caller is responsible for
  passing UT; TAI/UT1 offset (< 1 s) is ignored — the low-precision
  lunar series doesn't resolve it anyway.
  """
  @spec julian_day(DateTime.t()) :: float()
  def julian_day(%DateTime{} = dt) do
    day_frac = (dt.hour + dt.minute / 60 + dt.second / 3600) / 24

    {y, m} =
      if dt.month <= 2 do
        {dt.year - 1, dt.month + 12}
      else
        {dt.year, dt.month}
      end

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

    trunc(365.25 * (y + 4716)) + trunc(30.6001 * (m + 1)) + dt.day + day_frac + b - 1524.5
  end

  @doc """
  Geocentric ecliptic → equatorial position of the Moon at a UTC
  instant. Returns `%{ra, dec, distance_km}` where `ra` and `dec`
  are in degrees.
  """
  @spec geocentric(DateTime.t()) :: %{ra: float(), dec: float(), distance_km: float()}
  def geocentric(%DateTime{} = dt) do
    jd = julian_day(dt)
    t = (jd - 2_451_545.0) / 36_525.0

    # Mean-element arguments (degrees, wrapped before trig conversion).
    l = 218.32 + 481_267.881 * t
    m_prime = 134.9 + 477_198.85 * t
    m = 357.53 + 35_999.05 * t
    d = 297.85 + 445_267.11 * t
    f = 93.27 + 483_202.03 * t

    m_prime_r = deg_to_rad(m_prime)
    m_r = deg_to_rad(m)
    d_r = deg_to_rad(d)
    f_r = deg_to_rad(f)

    # Ecliptic longitude λ (degrees).
    lambda =
      l + 6.29 * :math.sin(m_prime_r) -
        1.27 * :math.sin(m_prime_r - 2 * d_r) +
        0.66 * :math.sin(2 * d_r) +
        0.21 * :math.sin(2 * m_prime_r) -
        0.19 * :math.sin(m_r) -
        0.11 * :math.sin(2 * f_r)

    # Ecliptic latitude β (degrees).
    beta =
      5.13 * :math.sin(f_r) +
        0.28 * :math.sin(m_prime_r + f_r) -
        0.28 * :math.sin(m_prime_r - f_r) -
        0.17 * :math.sin(2 * d_r - f_r)

    # Horizontal parallax π (degrees).
    parallax =
      0.9508 +
        0.0518 * :math.cos(m_prime_r) +
        0.0095 * :math.cos(m_prime_r - 2 * d_r) +
        0.0078 * :math.cos(2 * d_r) +
        0.0028 * :math.cos(2 * m_prime_r)

    distance_km = @earth_radius_km / :math.sin(deg_to_rad(parallax))

    # Mean obliquity of the ecliptic (IAU 1980).
    epsilon = 23.4393 - 0.0130 * t

    lambda_r = deg_to_rad(lambda)
    beta_r = deg_to_rad(beta)
    eps_r = deg_to_rad(epsilon)

    sin_dec =
      :math.sin(beta_r) * :math.cos(eps_r) +
        :math.cos(beta_r) * :math.sin(eps_r) * :math.sin(lambda_r)

    dec = rad_to_deg(:math.asin(sin_dec))

    ra =
      (:math.sin(lambda_r) * :math.cos(eps_r) - :math.tan(beta_r) * :math.sin(eps_r))
      |> :math.atan2(:math.cos(lambda_r))
      |> rad_to_deg()
      |> normalize_360()

    %{ra: ra, dec: dec, distance_km: distance_km}
  end

  @doc """
  Topocentric Moon position for an observer at `(lat, lon)` degrees,
  UTC `dt`. Returns azimuth ∈ [0, 360), elevation ∈ [-90, 90], and
  the geocentric slant range (the topocentric correction is below
  the module's stated accuracy floor).
  """
  @spec observer_position(DateTime.t(), float(), float()) ::
          %{azimuth: float(), elevation: float(), distance_km: float()}
  def observer_position(%DateTime{} = dt, lat_deg, lon_deg) do
    g = geocentric(dt)

    jd = julian_day(dt)
    t = (jd - 2_451_545.0) / 36_525.0

    # Greenwich Mean Sidereal Time — USNO polynomial (degrees).
    gmst =
      normalize_360(
        280.46061837 + 360.98564736629 * (jd - 2_451_545.0) + 0.000387933 * t * t -
          t * t * t / 38_710_000.0
      )

    lst = normalize_360(gmst + lon_deg)
    hour_angle = normalize_360(lst - g.ra)

    lat_r = deg_to_rad(lat_deg)
    dec_r = deg_to_rad(g.dec)
    ha_r = deg_to_rad(hour_angle)

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

    elevation = rad_to_deg(:math.asin(sin_el))

    azimuth =
      -:math.sin(ha_r)
      |> :math.atan2(:math.tan(dec_r) * :math.cos(lat_r) - :math.sin(lat_r) * :math.cos(ha_r))
      |> rad_to_deg()
      |> normalize_360()

    %{azimuth: azimuth, elevation: elevation, distance_km: g.distance_km}
  end

  @doc """
  Radial velocity of the Moon relative to an observer at `(lat, lon)`
  in km/s at UTC instant `dt`. Positive = the Moon is moving away
  (increasing slant range), negative = approaching.

  Computed via centred finite difference on the geocentric distance,
  which tracks the dominant rate (the Earth-Moon elliptical orbit
  plus the observer's diurnal baseline contribution is below 100 m/s).
  Accurate enough to size the ±kHz Doppler shift EME operators care
  about at the VHF/microwave bands this project supports.
  """
  @spec radial_velocity_km_s(DateTime.t(), float(), float()) :: float()
  def radial_velocity_km_s(%DateTime{} = dt, lat_deg, lon_deg) do
    before = DateTime.add(dt, -1, :second)
    later = DateTime.add(dt, 1, :second)

    d_before = observer_position(before, lat_deg, lon_deg).distance_km
    d_later = observer_position(later, lat_deg, lon_deg).distance_km

    # Centred difference in km per 2 s → km/s.
    (d_later - d_before) / 2.0
  end

  # --- Helpers --------------------------------------------------------

  defp deg_to_rad(d), do: d * :math.pi() / 180.0
  defp rad_to_deg(r), do: r * 180.0 / :math.pi()

  defp normalize_360(x) do
    r = :math.fmod(x, 360.0)
    if r < 0, do: r + 360.0, else: r
  end
end
