#' Normalize a vector to unit length
#'
#' @description Scale a numeric vector so it has unit Euclidean length.
#' @param v Numeric vector to normalise.
#' @keywords internal
normalize = function(v) {
  s = sqrt(sum(v * v))
  if (s == 0) {
    stop("Zero-length vector")
  }
  return(v / s)
}

#' Convert altitude/azimuth to ENU
#'
#' @description Map altitude/azimuth angles (north-referenced) to a unit
#' East-North-Up direction vector.
#' @param alt_rad Altitude in radians.
#' @param azN_rad Azimuth in radians measured from north.
#' @keywords internal
altaz_to_enu = function(alt_rad, azN_rad) {
  east = sin(azN_rad) * cos(alt_rad)
  north = cos(azN_rad) * cos(alt_rad)
  up = sin(alt_rad)
  normalize(c(east, north, up)) # (E, N, U) matches engine XYZ
}


#' Moon V-band magnitude from phase angle
#'
#' @param phase_deg Phase angle psi in degrees (0 = full Moon, 180 = new).
#' @param dist_km   Default `384400`. Current geocentric distance (km).
#' @param mean_km   Default `384400`. Mean distance (km).
#' @keywords internal
#'
#' @return Apparent V magnitude `m`.
moon_mag = function(
  phase_deg,
  dist_km = 384400,
  mean_km = 384400
) {
  # Allen-1976 magnitude for a disc at mean distance, no surge (phase_deg in degrees):
  m0 = -12.73 + 0.026 * abs(phase_deg) + 4e-9 * phase_deg^4

  # Geometry: inverse-square scaling with distance
  geom = 5 * log10(dist_km / mean_km)

  # Opposition surge term (Allen's "extra" brightness near full Moon)
  S = max(1, 1.35 - 0.05 * abs(phase_deg)) # Allen's empirical fit
  surge_corr = -2.5 * log10(S)

  m = m0 + geom + surge_corr
  return(m)
}


# Krisciunas & Schaefer (1991) / Rozenberg (1966) airmass approximation:
# X(Z) = [cos(Z) + 0.025 * exp(-11 * cos(Z))]^(-1)
# where Z is zenith distance in radians; bounded to ~40 at the horizon.
airmass_rozenberg1966 = function(zenith_deg) {
  z = zenith_deg * pi / 180
  c = cos(z)
  1 / (c + 0.025 * exp(-11 * c))
}

apply_airmass_extinction = function(lux_top_atm, alt_deg, kV = 0.172) {
  # No direct-beam moonlight below (or at) the horizon
  if (!is.finite(lux_top_atm) || !is.finite(alt_deg) || alt_deg <= 0) {
    return(0)
  }

  # Rozenberg is for 0 <= Z <= 90 deg; clamp tiny numerical overshoots
  alt_deg = min(alt_deg, 90)
  zenith_deg = 90 - alt_deg

  X = airmass_rozenberg1966(zenith_deg)
  lux_top_atm * 10^(-0.4 * kV * X)
}

#' Compute topocentric moon/sun directions
#'
#' @description Query Swiss Ephemeris for topocentric sun and moon direction
#' vectors and magnitudes for a given observer location.
#' @param datetime POSIXct observation time (UTC).
#' @param lat Latitude in degrees.
#' @param lon Longitude in degrees.
#' @param elev_m Observer elevation above sea level in metres.
#' @param moon_extinction_kV Default `0.172`. V-band atmospheric extinction
#'   coefficient used in the Rozenberg/Krisciunas-Schaefer airmass attenuation
#'   model for moonlight.
#' @keywords internal
swe_dirs_topo_moon_sun = function(
  datetime,
  lat,
  lon,
  elev_m = 0,
  moon_extinction_kV = 0.172
) {
  swephR::swe_set_topo(lon, lat, elev_m)
  attr(datetime, "tzone") = "UTC"
  ts = as.POSIXlt(datetime, tz = "UTC")
  jd_ut = swephR::swe_utc_to_jd(
    ts$year + 1900,
    ts$mon + 1,
    ts$mday,
    ts$hour,
    ts$min,
    ts$sec,
    swephR::SE$GREG_CAL
  )$dret[1]

  flg_eq_topo = swephR::SE$FLG_SWIEPH +
    swephR::SE$FLG_EQUATORIAL +
    swephR::SE$FLG_TOPOCTR
  long_rad = lon * pi / 180
  lat_rad = lat * pi / 180

  enu_to_ecef = matrix(
    c(
      -sin(long_rad),
      cos(long_rad),
      0,
      -sin(lat_rad) * cos(long_rad),
      -sin(lat_rad) * sin(long_rad),
      cos(lat_rad),
      cos(lat_rad) * cos(long_rad),
      cos(lat_rad) * sin(long_rad),
      sin(lat_rad)
    ),
    ncol = 3,
    nrow = 3,
    byrow = TRUE
  )
  sun_t = swephR::swe_calc_ut(jd_ut, swephR::SE$SUN, flg_eq_topo)$xx
  moon_t = swephR::swe_calc_ut(jd_ut, swephR::SE$MOON, flg_eq_topo)$xx
  moon_size = swephR::swe_pheno_ut(jd_ut, swephR::SE$MOON, flg_eq_topo)$attr
  sun_size = swephR::swe_pheno_ut(jd_ut, swephR::SE$SUN, flg_eq_topo)$attr

  dist_au = moon_t[3]
  moon_distance_km = dist_au * 149597870.7

  moon_phase = moon_size[1]
  moon_diameter_degrees = moon_size[4]
  moon_brightness_magnitude = moon_size[5]

  m = moon_mag(
    moon_phase,
    dist_km = moon_distance_km,
    mean_km = 384400
  )

  ftcnds_to_lux = 10.76
  moon_brightness_lux_raw = ftcnds_to_lux * 10^(-0.4 * (m + 16.57))

  sun_diameter_degrees = sun_size[4]
  sun_brightness_magnitude = sun_size[5]
  sun_brightness_lux = ftcnds_to_lux *
    10^(-0.4 * (sun_brightness_magnitude + 16.57))

  to_local_horizontal_coordinates = function(ra_dec) {
    aa = swephR::swe_azalt(
      jd_ut,
      swephR::SE$EQU2HOR,
      c(lon, lat, elev_m),
      0,
      0,
      xin = c(ra_dec[1], ra_dec[2], 1)
    )$xaz
    aa
  }
  to_rad = pi / 180

  local_horizon_sun = to_local_horizontal_coordinates(sun_t) * to_rad
  local_horizon_moon = to_local_horizontal_coordinates(moon_t) * to_rad

  azN_s = local_horizon_sun[1]
  alt_s = local_horizon_sun[2]
  azN_mt = local_horizon_moon[1]
  alt_mt = local_horizon_moon[2]

  moon_elev_deg = alt_mt * 180 / pi
  moon_brightness_lux = apply_airmass_extinction(
    lux_top_atm = moon_brightness_lux_raw,
    alt_deg = moon_elev_deg,
    kV = moon_extinction_kV
  )

  list(
    sun_dir_topo = altaz_to_enu(alt_s, azN_s),
    sun_ecef_geo = enu_to_ecef %*% altaz_to_enu(alt_s, azN_s),
    moon_dir_topo = altaz_to_enu(alt_mt, azN_mt),
    moon_ecef_geo = enu_to_ecef %*% altaz_to_enu(alt_mt, azN_mt),
    local_up_geo = enu_to_ecef %*% c(0, 0, 1),
    moon_diameter_degrees = moon_diameter_degrees,
    moon_brightness_magnitude = moon_brightness_magnitude,
    moon_brightness_lux = moon_brightness_lux, #lux
    moon_phase = moon_phase,
    sun_diameter_degrees = sun_diameter_degrees,
    sun_brightness_magnitude = sun_brightness_magnitude,
    sun_brightness_lux = sun_brightness_lux #lux
  ) |>
    lapply(as.numeric)
}


#' Convert Swiss ephemeris data to a starfield frame
#'
#' @description Build a data frame compatible with `make_starfield_rcpp()` from
#' Swiss Ephemeris results.
#' @param sweph_info List of planetary info objects.
#' @keywords internal
sweph_info_to_stars_df = function(sweph_info) {
  sweph_info_df = do.call(rbind, lapply(sweph_info, as.data.frame))
  planets = rownames(sweph_info_df)
  data.frame(
    planet = planets,
    ra_rad = sweph_info_df$ra_rad,
    dec_rad = sweph_info_df$dec_rad,
    ra_deg = sweph_info_df$ra_rad * 180 / pi,
    dec_deg = sweph_info_df$dec_rad * 180 / pi,
    v_mag = sweph_info_df$v_mag,
    r = 1,
    g = 1,
    b = 1
  )
}

#' Compute sun/moon positions via Swiss Ephemeris
#'
#' @description Produce a data frame of apparent positions and
#' magnitudes for the observer.
#' @param datetime POSIXct observation time (UTC).
#' @param lat Latitude in degrees.
#' @param lon Longitude in degrees.
#' @param elev_m Observer elevation above sea level in metres.
#' @keywords internal
swe_dirs_topo_sunmoon_df = function(datetime, lat, lon, elev_m = 0) {
  swephR::swe_set_topo(lon, lat, elev_m)
  attr(datetime, "tzone") = "UTC"
  ts = as.POSIXlt(datetime, tz = "UTC")
  jd_ut = swephR::swe_utc_to_jd(
    ts$year + 1900,
    ts$mon + 1,
    ts$mday,
    ts$hour,
    ts$min,
    ts$sec,
    swephR::SE$GREG_CAL
  )$dret[1]

  flg_eq_topo = swephR::SE$FLG_SWIEPH +
    swephR::SE$FLG_EQUATORIAL +
    swephR::SE$FLG_TOPOCTR

  to_rad = pi / 180
  get_enu_magnitude = function(planet_flag) {
    position_rad = swephR::swe_calc_ut(jd_ut, planet_flag, flg_eq_topo)$xx *
      to_rad
    info = swephR::swe_pheno_ut(jd_ut, planet_flag, flg_eq_topo)$attr
    list(ra_rad = position_rad[1], dec_rad = position_rad[2], v_mag = info[5])
  }
  info = list(
    sun = get_enu_magnitude(swephR::SE$SUN),
    moon = get_enu_magnitude(swephR::SE$MOON)
  )
  return(sweph_info_to_stars_df(info))
}

#' Compute bright-planet positions via Swiss Ephemeris
#'
#' @description Produce a data frame of planetary apparent positions and
#' magnitudes for the observer.
#' @param datetime POSIXct observation time (UTC).
#' @param lat Latitude in degrees.
#' @param lon Longitude in degrees.
#' @param elev_m Observer elevation above sea level in metres.
#' @keywords internal
swe_dirs_topo_planets_df = function(datetime, lat, lon, elev_m = 0) {
  swephR::swe_set_topo(lon, lat, elev_m)
  attr(datetime, "tzone") = "UTC"
  ts = as.POSIXlt(datetime, tz = "UTC")
  jd_ut = swephR::swe_utc_to_jd(
    ts$year + 1900,
    ts$mon + 1,
    ts$mday,
    ts$hour,
    ts$min,
    ts$sec,
    swephR::SE$GREG_CAL
  )$dret[1]

  flg_eq_topo = swephR::SE$FLG_SWIEPH +
    swephR::SE$FLG_EQUATORIAL +
    swephR::SE$FLG_TOPOCTR

  to_rad = pi / 180
  get_enu_magnitude = function(planet_flag) {
    position_rad = swephR::swe_calc_ut(jd_ut, planet_flag, flg_eq_topo)$xx *
      to_rad
    info = swephR::swe_pheno_ut(jd_ut, planet_flag, flg_eq_topo)$attr
    list(ra_rad = position_rad[1], dec_rad = position_rad[2], v_mag = info[5])
  }
  planet_info = list(
    mercury = get_enu_magnitude(swephR::SE$MERCURY),
    venus = get_enu_magnitude(swephR::SE$VENUS),
    mars = get_enu_magnitude(swephR::SE$MARS),
    jupiter = get_enu_magnitude(swephR::SE$JUPITER),
    saturn = get_enu_magnitude(swephR::SE$SATURN),
    uranus = get_enu_magnitude(swephR::SE$URANUS),
    neptune = get_enu_magnitude(swephR::SE$NEPTUNE)
  )
  return(sweph_info_to_stars_df(planet_info))
}


#' Generate the moon into a lat-long image array patch
#'
#' @description Produce a rasterised moon texture aligned for composition into
#' the sky dome. The Earth phase is inferred from the Sun-Moon geometry, and earthshine is
#' implemented as an emissive term scaled relative to solar irradiance.
#'
#' @param datetime POSIXct observation time (UTC).
#' @param lat Latitude in degrees.
#' @param lon Longitude in degrees.
#' @param elev_m Observer elevation above sea level in metres.
#' @param width Output width in pixels.
#' @param height Output height in pixels.
#' @param earthshine Default `TRUE`. If `FALSE`, skip the earthshine emissive term.
#' @param earthshine_albedo Default `0.19`. Effective Earth albedo term used
#'   in the earthshine irradiance approximation.
#' @param solar_irradiance_w_m2 Default `1300`. Reference solar irradiance at
#'   1 AU (W/m^2) used to normalize earthshine emission intensity.
#' @param moon_extinction_kV Default `0.172`. V-band atmospheric extinction
#'   coefficient for direct moonlight attenuation.
#' @param verbose Default `FALSE`. If `TRUE`, print earthshine diagnostics.
#' @keywords internal
generate_moon_image_latlong = function(
  datetime,
  lat,
  lon,
  elev_m = 0,
  width = 800,
  height = 800,
  earthshine = TRUE,
  earthshine_albedo = 0.19,
  solar_irradiance_w_m2 = 1300,
  moon_extinction_kV = 0.172,
  verbose = FALSE
) {
  moon_sun_data = swe_dirs_topo_moon_sun(
    datetime = datetime,
    lat = lat,
    lon = lon,
    elev_m = elev_m,
    moon_extinction_kV = moon_extinction_kV
  )
  dir_moon = normalize(moon_sun_data$moon_ecef_geo)
  dir_sun = -normalize(moon_sun_data$sun_ecef_geo)
  local_up = normalize(moon_sun_data$local_up_geo)

  tex = get_moon_texture()

  cot = function(x) {
    1 / tan(x)
  }

  cos_phi = clamp_value(sum(dir_moon * dir_sun), -1, 1)
  phi = acos(cos_phi)

  earth_phase = pi - phi
  earth_phase = clamp_value(earth_phase, 1e-6, pi - 1e-6)

  # Paper-based earthshine irradiance model:
  # E_em = A_e * 0.5 * [1 - sin(phase/2) * tan(phase/2) * ln(cot(phase/4))]
  E_em = earthshine_albedo *
    0.5 *
    (1 -
      sin(earth_phase / 2) *
        tan(earth_phase / 2) *
        log(cot(earth_phase / 4)))

  if (!is.finite(E_em) || E_em < 0) {
    E_em = 0
  }

  earth_emissive_intensity = 0
  if (
    earthshine && is.finite(solar_irradiance_w_m2) && solar_irradiance_w_m2 > 0
  ) {
    earth_emissive_intensity = E_em / solar_irradiance_w_m2
  }

  # Build a base mesh transform for the moon pass
  build_moon_mesh = function(material) {
    rayvertex::sphere_mesh(material = material, radius = 0.5) |>
      rayvertex::subdivide_mesh() |>
      rayvertex::rotate_mesh(c(0, -90, 6.68), order_rotation = c(3, 1, 2)) |>
      rayvertex::transform_mesh(rayvertex::lookat_transform(
        pos = -dir_moon,
        look = c(0, 0, 0),
        up = c(0, 0, 1)
      )) |>
      rayvertex::translate_mesh(dir_moon * 10)
  }

  # Combined sun + earthshine using emissive intensity
  moon_material = rayvertex::material_list(
    texture_location = tex,
    emissive_texture_location = tex,
    diffuse = c(1, 1, 1),
    emission = c(1, 1, 1),
    diffuse_intensity = 1,
    emission_intensity = earth_emissive_intensity,
    sigma = 10
  )

  moon_mesh = build_moon_mesh(moon_material)

  moon_image = rayvertex::rasterize_scene(
    moon_mesh,
    lookfrom = c(0, 0, 0),
    lookat = dir_moon * 10,
    camera_up = local_up,
    fov = 0,
    ortho_dimensions = c(2, 2), # Keep edge effects away from the visible disk
    light_info = rayvertex::directional_light(
      direction = -dir_sun,
      intensity = 1
    ),
    transparent_background = TRUE,
    plot = FALSE,
    width = width,
    height = height
  )

  if (verbose) {
    message(sprintf(
      "phi=%0.4f, earth_phase=%0.4f, E_em=%0.4g W/m^2, emission_intensity=%0.4g",
      phi,
      earth_phase,
      E_em,
      earth_emissive_intensity
    ))
  }

  moon_image[,, 4][moon_image[,, 4] > 1] = 1

  list(
    moon_luminance_array = moon_image,
    moon_angular_diameter_deg = moon_sun_data$moon_diameter_degrees
  )
}
