"""
Planetary position calculations for libephemeris.

This is the core module providing planet position calculations
using NASA JPL DE440 ephemeris via Skyfield.

Supported Bodies:
- Classical planets: Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto
- Earth position
- Lunar nodes (Mean/True) via lunar.py
- Lilith/Lunar apogee (Mean/True) via lunar.py
- Minor bodies (asteroids, TNOs) via minor_bodies.py
- Fixed stars via fixed_stars.py
- Astrological angles via angles.py
- Arabic parts via arabic_parts.py

Main Functions:
- swe_calc_ut(): Calculate positions in Universal Time
- swe_calc(): Calculate positions in Ephemeris Time
- swe_set_sid_mode(): Set sidereal zodiac mode
- swe_get_ayanamsa_ut(): Get ayanamsha value

Coordinate Systems:
- Geocentric tropical (default)
- Heliocentric (with SEFLG_HELCTR)
- Topocentric (requires swe_set_topo)
- Sidereal (requires swe_set_sid_mode)

Precision Notes:
- Nutation: IAU 2006/2000A model via pyerfa (~0.01-0.05 mas precision)
- Obliquity: IAU 2006 model via pyerfa (consistent across all code paths)
- Precession: IAU 2006 (Capitaine et al. 2003) with terms up to T^5
- Ayanamsa: Properly converts ET to UT using Delta T
- Planet positions: JPL DE440 (accurate to ~0.001" for modern dates)
- Planets use NAIF planet center IDs (599, 699, etc.) for accurate positions
- Ecliptic frame uses true ecliptic of date (Skyfield ecliptic_frame with IAU 2006 precession + IAU 2000A nutation)

References:
- JPL DE421 ephemeris (accurate to ~0.001 arcsecond for modern dates)
- IAU 2000B nutation model via Skyfield
- Reference API compatibility layer
"""

from __future__ import annotations

import math
import warnings
from typing import Tuple, TYPE_CHECKING
from jplephem.exceptions import OutOfRangeError
from skyfield.errors import EphemerisRangeError as SkyfieldRangeError
from skyfield.api import Star
from skyfield.framelib import ecliptic_frame
from dataclasses import dataclass
import erfa

if TYPE_CHECKING:
    from .exceptions import EphemerisRangeError

# Combined exception tuple for catching both jplephem and Skyfield range errors
_RANGE_ERRORS = (OutOfRangeError, SkyfieldRangeError)

from .constants import (
    SE_SUN,
    SE_MOON,
    SE_MERCURY,
    SE_VENUS,
    SE_MARS,
    SE_JUPITER,
    SE_SATURN,
    SE_URANUS,
    SE_NEPTUNE,
    SE_PLUTO,
    SE_EARTH,
    SE_MEAN_NODE,
    SE_TRUE_NODE,
    SE_MEAN_APOG,
    SE_OSCU_APOG,
    SE_INTP_APOG,
    SE_INTP_PERG,
    SE_CUPIDO,
    SE_POSEIDON,
    SE_ISIS,
    SE_CHIRON,
    SE_PHOLUS,
    SE_CERES,
    SE_PALLAS,
    SE_JUNO,
    SE_VESTA,
    SEFLG_SPEED,
    SEFLG_HELCTR,
    SEFLG_TOPOCTR,
    SEFLG_SIDEREAL,
    SE_ANGLE_OFFSET,
    SE_ARABIC_OFFSET,
    SEFLG_BARYCTR,
    SEFLG_TRUEPOS,
    SEFLG_NOABERR,
    SEFLG_EQUATORIAL,
    SEFLG_J2000,
    SE_PARS_FORTUNAE,
    SE_PARS_SPIRITUS,
    SE_PARS_AMORIS,
    SE_PARS_FIDEI,
)

# Import all sidereal mode constants (SE_SIDM_*)
from .constants import *  # noqa: F403, F401
from .state import get_planets, get_timescale, get_topo, get_sid_mode

# Planet mapping: Primary names for planets
# For outer planets, uses planet center (NAIF x99) if available in ephemeris,
# otherwise falls back to system barycenter (NAIF x)
# DE421: has centers for Mercury/Venus/Earth/Mars (199/299/399/499), barycenters for Jupiter+
# DE430/440/441: has centers only for Mercury/Venus/Earth (199/299/399), barycenters for Mars+
_PLANET_MAP = {
    SE_SUN: "sun",
    SE_MOON: "moon",
    SE_MERCURY: "mercury",  # 199
    SE_VENUS: "venus",  # 299
    SE_MARS: "mars",  # 499
    SE_JUPITER: "jupiter",  # 599 if available, else barycenter 5
    SE_SATURN: "saturn",  # 699 if available, else barycenter 6
    SE_URANUS: "uranus",  # 799 if available, else barycenter 7
    SE_NEPTUNE: "neptune",  # 899 if available, else barycenter 8
    SE_PLUTO: "pluto",  # 999 if available, else barycenter 9
    SE_EARTH: "earth",
}

# Fallback mapping when planet center not available in ephemeris
# DE430/440/441 only contain barycenters for Mars and outer planets
_PLANET_FALLBACK = {
    "mars": "mars barycenter",
    "jupiter": "jupiter barycenter",
    "saturn": "saturn barycenter",
    "uranus": "uranus barycenter",
    "neptune": "neptune barycenter",
    "pluto": "pluto barycenter",
}

# Planet ID to human-readable name mapping for error messages and debugging
_PLANET_NAMES = {
    SE_SUN: "Sun",
    SE_MOON: "Moon",
    SE_MERCURY: "Mercury",
    SE_VENUS: "Venus",
    SE_MARS: "Mars",
    SE_JUPITER: "Jupiter",
    SE_SATURN: "Saturn",
    SE_URANUS: "Uranus",
    SE_NEPTUNE: "Neptune",
    SE_PLUTO: "Pluto",
    SE_MEAN_NODE: "Mean Node",
    SE_TRUE_NODE: "True Node",
    SE_MEAN_APOG: "Mean Apogee",
    SE_OSCU_APOG: "Osculating Apogee",
    SE_INTP_APOG: "Interpolated Apogee",
    SE_INTP_PERG: "Interpolated Perigee",
    SE_EARTH: "Earth",
    SE_ISIS: "Transpluto",
    SE_CHIRON: "Chiron",
    SE_PHOLUS: "Pholus",
    SE_CERES: "Ceres",
    SE_PALLAS: "Pallas",
    SE_JUNO: "Juno",
    SE_VESTA: "Vesta",
}


class _CobCorrectedTarget:
    """Wrapper that applies COB (Center of Body) correction to barycenter positions.

    DE440 returns system barycenter positions for outer planets (Jupiter, Saturn,
    Neptune, Pluto), but astrological calculations expect planet center positions. This
    wrapper applies analytical moon theory corrections to convert barycenter to
    center of body positions.

    The wrapper implements the Skyfield VectorFunction protocol so it can be used
    with observer.at(t).observe(target) seamlessly.
    """

    def __init__(self, barycenter, barycenter_name: str):
        """Initialize with a barycenter target and its name.

        Args:
            barycenter: Skyfield VectorFunction (e.g., planets['jupiter barycenter'])
            barycenter_name: Name like 'jupiter barycenter' for lookup in moon_theories
        """
        self._barycenter = barycenter
        self._barycenter_name = barycenter_name
        # Copy attributes needed by Skyfield
        self.center = getattr(barycenter, "center", 0)
        self.target = getattr(barycenter, "target", None)

    def at(self, t):
        """Return position at time t with COB correction applied.

        Args:
            t: Skyfield Time object

        Returns:
            Skyfield ICRF position with COB offset applied
        """
        import numpy as np
        from skyfield.positionlib import ICRF

        from .moon_theories import get_cob_offset
        from .state import get_timescale

        # Get barycenter position
        bary_pos = self._barycenter.at(t)
        pos_au = bary_pos.position.au
        vel_au_per_d = bary_pos.velocity.au_per_d

        # Apply COB correction
        offset = get_cob_offset(self._barycenter_name, t)
        corrected_pos = pos_au + np.array(offset)

        # Compute velocity correction using central difference
        dt = 1.0 / 86400.0  # 1 second in days
        ts = get_timescale()
        t_prev = ts.tt_jd(t.tt - dt)
        t_next = ts.tt_jd(t.tt + dt)
        offset_prev = get_cob_offset(self._barycenter_name, t_prev)
        offset_next = get_cob_offset(self._barycenter_name, t_next)
        offset_vel = (np.array(offset_next) - np.array(offset_prev)) / (2.0 * dt)
        corrected_vel = vel_au_per_d + offset_vel

        # Return new ICRF position with corrected coordinates
        return ICRF(
            corrected_pos,
            corrected_vel,
            t=t,
            center=self.center,
        )

    def __repr__(self):
        return f"<CobCorrectedTarget {self._barycenter_name}>"

    def _observe_from_bcrs(self, observer):
        """Observe this target from an observer in BCRS coordinates.

        This method is called by Skyfield's observe() function to compute
        light-time corrected positions. We delegate to the barycenter's
        implementation and then apply the COB correction.

        Args:
            observer: Skyfield Barycentric position of the observer

        Returns:
            Tuple of (position_au, velocity_au_per_d, time, light_time_days)
        """
        import numpy as np

        from .moon_theories import get_cob_offset
        from .state import get_timescale

        # Get the raw observation from the barycenter
        pos, vel, t, light_time = self._barycenter._observe_from_bcrs(observer)

        # Apply COB correction at the retarded time
        # (when light left the planet, not when it arrives)
        # The returned 't' should be the retarded time
        offset = get_cob_offset(self._barycenter_name, t)
        corrected_pos = pos + np.array(offset)

        # Compute velocity correction using central difference
        dt = 1.0 / 86400.0  # 1 second in days
        ts = get_timescale()
        t_prev = ts.tt_jd(t.tt - dt)
        t_next = ts.tt_jd(t.tt + dt)
        offset_prev = get_cob_offset(self._barycenter_name, t_prev)
        offset_next = get_cob_offset(self._barycenter_name, t_next)
        offset_vel = (np.array(offset_next) - np.array(offset_prev)) / (2.0 * dt)
        corrected_vel = vel + offset_vel

        return corrected_pos, corrected_vel, t, light_time


class _SpkCenterTarget:
    """Wrapper that combines barycenter position with SPK-based center offset.

    This class uses precise planet center segments from planet_centers.bsp to
    compute the offset from system barycenter to planet center. This provides
    higher precision (<0.001 arcsec) than the analytical COB corrections.

    The wrapper implements the Skyfield VectorFunction protocol so it can be used
    with observer.at(t).observe(target) seamlessly.
    """

    def __init__(
        self, barycenter, center_segment, planet_name: str, barycenter_name: str
    ):
        """Initialize with a barycenter target and center segment.

        Args:
            barycenter: Skyfield VectorFunction (e.g., planets['jupiter barycenter'])
            center_segment: Skyfield ChebyshevPosition segment from planet_centers.bsp
            planet_name: Planet name for debugging (e.g., 'jupiter')
        """
        self._barycenter = barycenter
        self._center_segment = center_segment
        self._planet_name = planet_name
        self._barycenter_name = barycenter_name
        # Copy attributes needed by Skyfield
        self.center = getattr(barycenter, "center", 0)
        self.target = getattr(barycenter, "target", None)

    def at(self, t):
        """Return position at time t with SPK center offset applied.

        Args:
            t: Skyfield Time object

        Returns:
            Skyfield ICRF position with center offset applied
        """
        from skyfield.positionlib import ICRF

        # Get barycenter position (from SSB)
        bary_pos = self._barycenter.at(t)
        pos_au = bary_pos.position.au
        vel_au_per_d = bary_pos.velocity.au_per_d

        try:
            # Get center offset from SPK segment (relative to barycenter)
            center_offset = self._center_segment.at(t)
            offset_au = center_offset.position.au
            offset_vel = center_offset.velocity.au_per_d

            # Combine: barycenter + offset = center
            corrected_pos = pos_au + offset_au
            corrected_vel = vel_au_per_d + offset_vel
        except _RANGE_ERRORS:
            # If SPK segment is out of range, fall back to COB correction
            import numpy as np
            from .moon_theories import get_cob_offset
            from .state import get_timescale

            offset = get_cob_offset(self._barycenter_name, t)
            corrected_pos = pos_au + np.array(offset)

            # Compute velocity correction using central difference
            dt = 1.0 / 86400.0  # 1 second in days
            ts = get_timescale()
            t_prev = ts.tt_jd(t.tt - dt)
            t_next = ts.tt_jd(t.tt + dt)
            offset_prev = get_cob_offset(self._barycenter_name, t_prev)
            offset_next = get_cob_offset(self._barycenter_name, t_next)
            offset_vel = (np.array(offset_next) - np.array(offset_prev)) / (2.0 * dt)
            corrected_vel = vel_au_per_d + offset_vel

        # Return new ICRF position with corrected coordinates
        return ICRF(
            corrected_pos,
            corrected_vel,
            t=t,
            center=self.center,
        )

    def __repr__(self):
        return f"<SpkCenterTarget {self._planet_name}>"

    def _observe_from_bcrs(self, observer):
        """Observe this target from an observer in BCRS coordinates.

        This method is called by Skyfield's observe() function to compute
        light-time corrected positions. We delegate to the barycenter's
        implementation and then apply the center offset.

        Args:
            observer: Skyfield Barycentric position of the observer

        Returns:
            Tuple of (position_au, velocity_au_per_d, time, light_time_days)
        """
        # Get the raw observation from the barycenter
        pos, vel, t, light_time = self._barycenter._observe_from_bcrs(observer)

        try:
            # Apply center offset at the retarded time
            center_offset = self._center_segment.at(t)
            offset_au = center_offset.position.au
            offset_vel = center_offset.velocity.au_per_d

            corrected_pos = pos + offset_au
            corrected_vel = vel + offset_vel
        except _RANGE_ERRORS:
            # If SPK segment is out of range, fall back to COB correction
            import numpy as np
            from .moon_theories import get_cob_offset
            from .state import get_timescale

            offset = get_cob_offset(self._barycenter_name, t)
            corrected_pos = pos + np.array(offset)

            # Compute velocity correction using central difference
            dt = 1.0 / 86400.0  # 1 second in days
            ts = get_timescale()
            t_prev = ts.tt_jd(t.tt - dt)
            t_next = ts.tt_jd(t.tt + dt)
            offset_prev = get_cob_offset(self._barycenter_name, t_prev)
            offset_next = get_cob_offset(self._barycenter_name, t_next)
            offset_vel = (np.array(offset_next) - np.array(offset_prev)) / (2.0 * dt)
            corrected_vel = vel + offset_vel

        return corrected_pos, corrected_vel, t, light_time


# Type alias for position result tuple
PositionResult = Tuple[float, float, float, float, float, float]


def _to_native_floats(values: tuple) -> PositionResult:
    """Convert numpy float types to native Python floats.

    Skyfield returns numpy.float64 values which can cause issues with:
    - JSON serialization
    - `is True` comparisons (speed < 0 returns np.bool_ instead of bool)
    - Type checking in downstream code

    This function ensures all values are native Python floats for
    compatibility with the pyswisseph API contract.

    Args:
        values: Tuple of 6 values (lon, lat, dist, speed_lon, speed_lat, speed_dist)

    Returns:
        Tuple of 6 native Python floats
    """
    return (
        float(values[0]),
        float(values[1]),
        float(values[2]),
        float(values[3]),
        float(values[4]),
        float(values[5]),
    )


def _body_uses_jpl_ephemeris(ipl: int) -> bool:
    """Check if a body uses the JPL ephemeris for calculations.

    Bodies that use the JPL ephemeris are subject to the ephemeris date range
    limitations (e.g., DE440 covers 1550-2650). Other bodies like lunar nodes,
    Lilith, hypothetical planets, and minor bodies with Keplerian elements
    are calculated mathematically and don't depend on the JPL ephemeris range.

    Args:
        ipl: Planet/body ID

    Returns:
        True if the body uses JPL ephemeris, False otherwise
    """
    # Standard planets use JPL ephemeris
    return ipl in _PLANET_MAP


def _wrap_ephemeris_range_error(
    skyfield_error: Exception,
    jd: float,
    body_id: int | None = None,
) -> "EphemerisRangeError":
    """
    Wrap Skyfield's EphemerisRangeError with enhanced error details.

    Creates a libephemeris.EphemerisRangeError with detailed information about:
    - The requested Julian Day number
    - The supported date range in both JD and calendar format
    - The body being calculated (if known)
    - The ephemeris file in use

    Args:
        skyfield_error: The original Skyfield EphemerisRangeError
        jd: The Julian Day that was requested
        body_id: The body ID being calculated (optional)

    Returns:
        EphemerisRangeError with enhanced error message
    """
    from .exceptions import EphemerisRangeError
    from . import state

    # Get ephemeris info
    path, start_jd, end_jd, denum = state.get_current_file_data(0)

    # Extract date strings from the original error message if available
    original_msg = str(skyfield_error)
    start_date = None
    end_date = None

    # Try to parse dates from "ephemeris segment only covers dates YYYY-MM-DD through YYYY-MM-DD"
    import re

    date_match = re.search(
        r"covers dates (\d{4}-\d{2}-\d{2}) through (\d{4}-\d{2}-\d{2})", original_msg
    )
    if date_match:
        start_date = date_match.group(1)
        end_date = date_match.group(2)

    # Convert requested JD to calendar date for the message
    from .time_utils import swe_revjul

    req_year, req_month, req_day, req_hour = swe_revjul(jd, 1)  # Gregorian calendar
    req_date_str = f"{req_year}-{req_month:02d}-{req_day:02d}"

    # Get body name if available
    body_name = None
    if body_id is not None:
        body_name = get_planet_name(body_id)

    # Get ephemeris filename
    ephemeris_file = None
    if path:
        import os

        ephemeris_file = os.path.basename(path)
    elif denum:
        ephemeris_file = f"de{denum}.bsp"

    # Build enhanced error message
    parts = []

    if body_name and body_id is not None:
        parts.append(f"Cannot calculate {body_name} (ID {body_id})")
    else:
        parts.append("Calculation failed")

    parts.append(f"for JD {jd:.6f} ({req_date_str}):")
    parts.append("date is outside ephemeris range.")

    if start_jd and end_jd:
        parts.append(f"\n  Supported range: JD {start_jd:.1f} to {end_jd:.1f}")
        if start_date and end_date:
            parts.append(f" ({start_date} to {end_date})")
    elif start_date and end_date:
        parts.append(f"\n  Supported range: {start_date} to {end_date}")

    if ephemeris_file:
        parts.append(f"\n  Ephemeris file: {ephemeris_file}")

    message = " ".join(parts[:3]) + "".join(parts[3:])

    return EphemerisRangeError(
        message=message,
        requested_jd=jd,
        start_jd=start_jd if start_jd else None,
        end_jd=end_jd if end_jd else None,
        start_date=start_date,
        end_date=end_date,
        body_id=body_id,
        body_name=body_name,
        ephemeris_file=ephemeris_file,
    )


# NAIF IDs for planet centers
_PLANET_CENTER_NAIF_IDS = {
    "jupiter": 599,
    "saturn": 699,
    "uranus": 799,
    "neptune": 899,
    "pluto": 999,
}


def get_planet_target(planets, target_name: str):
    """
    Get planet target from ephemeris with fallback to barycenter.

    For outer planets (Jupiter, Saturn, Uranus, Neptune, Pluto), this function
    first checks if planet_centers.bsp is available. If so, it returns a
    _SpkCenterTarget that combines the barycenter position with the precise
    SPK-based center offset.

    If planet_centers.bsp is not available, it falls back to the barycenter
    from the main ephemeris (or uses _CobCorrectedTarget for analytical COB
    corrections if configured).

    Args:
        planets: Skyfield SpiceKernel ephemeris object
        target_name: Planet name from _PLANET_MAP (e.g., 'jupiter', 'saturn')

    Returns:
        Skyfield planet object (or wrapper with center offset)

    Raises:
        KeyError: If neither planet center nor barycenter found in ephemeris
    """
    from .state import get_planet_center_segment

    # For outer planets, try to use SPK-based planet centers
    if target_name in _PLANET_CENTER_NAIF_IDS:
        naif_id = _PLANET_CENTER_NAIF_IDS[target_name]
        center_segment = get_planet_center_segment(naif_id)

        if center_segment is not None:
            # We have the planet center SPK segment
            # Get the barycenter from the main ephemeris
            barycenter_name = _PLANET_FALLBACK.get(target_name)
            if barycenter_name:
                try:
                    barycenter = planets[barycenter_name]
                    return _SpkCenterTarget(
                        barycenter, center_segment, target_name, barycenter_name
                    )
                except KeyError:
                    pass

    # Standard path: try planet center, then barycenter with COB correction
    try:
        return planets[target_name]
    except KeyError:
        if target_name in _PLANET_FALLBACK:
            barycenter_name = _PLANET_FALLBACK[target_name]
            barycenter = planets[barycenter_name]
            return _CobCorrectedTarget(barycenter, barycenter_name)
        raise


def get_planet_name(planet_id: int) -> str:
    """
    Get the human-readable name of a planet given its ID.

    Useful for error messages and debugging output.

    Args:
        planet_id: Planet/body ID (SE_SUN, SE_MOON, etc.)

    Returns:
        Human-readable planet name as a string.
        Returns "Unknown (ID)" for unrecognized planet IDs.

    Example:
        >>> get_planet_name(0)
        'Sun'
        >>> get_planet_name(1)
        'Moon'
        >>> get_planet_name(4)
        'Mars'
    """
    if planet_id in _PLANET_NAMES:
        return _PLANET_NAMES[planet_id]
    return f"Unknown ({planet_id})"


def _try_auto_spk_download(t, ipl: int, iflag: int):
    """
    Try to automatically download and use SPK for a minor body.

    This is called when auto SPK download is enabled and no SPK is registered
    for the given body. It downloads the SPK from JPL Horizons via direct HTTP
    (no external dependencies beyond urllib) and then calculates the position.

    Args:
        t: Skyfield Time object
        ipl: Planet/body ID
        iflag: Calculation flags

    Returns:
        Position tuple (lon, lat, dist, speed_lon, speed_lat, speed_dist)
        or None if download fails or body not supported.

    Note:
        This function uses spk.download_and_register_spk() which communicates
        directly with JPL Horizons API via HTTP. No astroquery dependency.
    """
    from . import spk
    from .constants import SPK_BODY_NAME_MAP
    from .logging_config import get_logger

    logger = get_logger()

    # Check if this body is in the SPK download map
    if ipl not in SPK_BODY_NAME_MAP:
        return None

    horizons_id, naif_id = SPK_BODY_NAME_MAP[ipl]

    try:
        # Use the date range from the current precision tier so the downloaded
        # SPK file matches the user's configured coverage level. The file
        # is cached on disk and will be reused on subsequent calls.
        from .state import get_spk_date_range_for_tier

        start_date, end_date = get_spk_date_range_for_tier()

        body_name = spk._get_body_name(ipl) or horizons_id
        logger.info("Auto-downloading SPK for %s from JPL Horizons...", body_name)

        # Download and register using direct HTTP (no astroquery needed).
        # Don't pass naif_id — let download_and_register_spk auto-detect from
        # the SPK file, since JPL Horizons uses the 20000000+N convention while
        # our constants may use the older 2000000+N convention.
        spk.download_and_register_spk(
            body=horizons_id,
            ipl=ipl,
            start=start_date,
            end=end_date,
        )

        # Now try to calculate using the newly registered SPK
        return spk.calc_spk_body_position(t, ipl, iflag)

    except Exception as e:
        logger.warning("Auto SPK download failed for body %d: %s", ipl, e)
        return None


def swe_calc_ut(
    tjd_ut: float, ipl: int, iflag: int
) -> Tuple[Tuple[float, float, float, float, float, float], int]:
    """
    Calculate planetary position for Universal Time.

    Reference API compatible function.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_SUN, SE_MOON, etc.)
        iflag: Calculation flags (SEFLG_SPEED, SEFLG_HELCTR, etc.)

    Returns:
        Tuple containing:
            - Position tuple: (longitude, latitude, distance, speed_lon, speed_lat, speed_dist)
            - Return flag: iflag value on success

    Raises:
        EphemerisRangeError: If the date is outside the ephemeris coverage

    Coordinate Output:
        - longitude: Ecliptic longitude in degrees (0-360)
        - latitude: Ecliptic latitude in degrees
        - distance: Distance in AU
        - speed_*: Daily motion in respective coordinates

    Ephemeris Selection Flags:
        The library always uses NASA JPL DE440 (or DE441 via env var) via Skyfield.

        - SEFLG_SWIEPH / SEFLG_JPLEPH (default): Uses NASA JPL DE440 ephemeris
          via Skyfield. Valid range depends on loaded ephemeris (DE440: 1550-2650 CE,
          DE441: -13200 to +17191 CE). Highest precision (sub-arcsecond).
          Supports all bodies including asteroids via SPK kernels.

        - SEFLG_MOSEPH: Accepted for API compatibility but **ignored**.
          Calculations always use JPL ephemeris regardless of this flag.

    Other Flags:
        - SEFLG_SPEED: Include velocity (default, always calculated)
        - SEFLG_HELCTR: Heliocentric instead of geocentric
        - SEFLG_TOPOCTR: Topocentric (requires swe_set_topo)
        - SEFLG_SIDEREAL: Sidereal zodiac (requires swe_set_sid_mode)

    Example:
        >>> pos, retflag = swe_calc_ut(2451545.0, SE_MARS, SEFLG_SPEED)
        >>> lon, lat, dist = pos[0], pos[1], pos[2]

        # For extended date range, set LIBEPHEMERIS_EPHEMERIS=de441.bsp
        >>> pos, retflag = swe_calc_ut(1000000.0, SE_MARS, SEFLG_SPEED)
    """
    from skyfield.errors import EphemerisRangeError as SkyfieldRangeError
    from .exceptions import validate_jd_range
    from .constants import SE_ECL_NUT, SEFLG_MOSEPH

    # Handle SE_ECL_NUT (-1) - returns nutation and obliquity
    if ipl == SE_ECL_NUT:
        return _calc_nutation_obliquity(tjd_ut, iflag)

    # Strip SEFLG_MOSEPH bit — accepted for compatibility, always uses JPL
    iflag = iflag & ~SEFLG_MOSEPH

    # Validate JD range for bodies that use the JPL ephemeris
    if _body_uses_jpl_ephemeris(ipl):
        validate_jd_range(tjd_ut, ipl, "swe_calc_ut")

    ts = get_timescale()
    t = ts.ut1_jd(tjd_ut)
    try:
        return _calc_body(t, ipl, iflag)
    except SkyfieldRangeError as e:
        raise _wrap_ephemeris_range_error(e, tjd_ut, ipl) from e


def swe_calc(
    tjd: float, ipl: int, iflag: int
) -> tuple[tuple[float, float, float, float, float, float], int]:
    """
    Calculate planetary position for Ephemeris Time (ET/TT).

    Reference API compatible function. Similar to swe_calc_ut() but takes
    Terrestrial Time (TT, also known as Ephemeris Time) instead of Universal Time.

    Args:
        tjd: Julian Day in Terrestrial Time (TT/ET)
        ipl: Planet/body ID (SE_SUN, SE_MOON, etc.)
        iflag: Calculation flags (SEFLG_SPEED, SEFLG_HELCTR, etc.)

    Returns:
        Tuple containing:
            - Position tuple: (longitude, latitude, distance, speed_lon, speed_lat, speed_dist)
            - Return flag: iflag value on success

    Raises:
        EphemerisRangeError: If the date is outside the ephemeris coverage

    Note:
        TT (Terrestrial Time) differs from UT (Universal Time) by Delta T,
        which varies from ~32 seconds (year 2000) to minutes (historical times).
        For most astrological applications, use swe_calc_ut() instead.

    Example:
        >>> pos, retflag = swe_calc(2451545.0, SE_JUPITER, SEFLG_SPEED)
        >>> lon, lat, dist = pos[0], pos[1], pos[2]
    """
    from skyfield.errors import EphemerisRangeError as SkyfieldRangeError
    from .exceptions import validate_jd_range
    from .constants import SEFLG_MOSEPH

    # Strip SEFLG_MOSEPH bit — accepted for compatibility, always uses JPL
    iflag = iflag & ~SEFLG_MOSEPH

    # Validate JD range for bodies that use the JPL ephemeris
    if _body_uses_jpl_ephemeris(ipl):
        validate_jd_range(tjd, ipl, "swe_calc")

    ts = get_timescale()
    t = ts.tt_jd(tjd)
    try:
        return _calc_body(t, ipl, iflag)
    except SkyfieldRangeError as e:
        raise _wrap_ephemeris_range_error(e, tjd, ipl) from e


def swe_calc_pctr(
    tjd_ut: float, ipl: int, iplctr: int, iflag: int
) -> Tuple[Tuple[float, float, float, float, float, float], int]:
    """
    Calculate planetary position as seen from another planet (planet-centric).

    Reference API compatible function.

    This function calculates the position of a target body (ipl) as observed
    from another body (iplctr) rather than from Earth (geocentric) or Sun
    (heliocentric). Useful for calculating, e.g., the position of Moon as
    seen from Mars, or Venus as seen from Jupiter.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Target planet/body ID (SE_SUN, SE_MOON, etc.)
        iplctr: Observer/center planet ID (the body from which to observe)
        iflag: Calculation flags (SEFLG_SPEED, etc.)

    Returns:
        Tuple containing:
            - Position tuple: (longitude, latitude, distance, speed_lon, speed_lat, speed_dist)
            - Return flag: iflag value on success

    Raises:
        EphemerisRangeError: If the date is outside the ephemeris coverage

    Note:
        - SEFLG_HELCTR and SEFLG_BARYCTR flags are ignored (observer is always iplctr)
        - SEFLG_TOPOCTR is ignored (no topocentric correction on other planets)
        - Distance is the distance from iplctr to ipl in AU

    Example:
        >>> # Position of Moon as seen from Mars
        >>> pos, retflag = swe_calc_pctr(2451545.0, SE_MOON, SE_MARS, SEFLG_SPEED)
        >>> print(f"Moon longitude from Mars: {pos[0]:.2f}°")
    """
    from skyfield.errors import EphemerisRangeError as SkyfieldRangeError
    from .exceptions import validate_jd_range

    # Validate JD range for bodies that use the JPL ephemeris
    if _body_uses_jpl_ephemeris(ipl) or _body_uses_jpl_ephemeris(iplctr):
        validate_jd_range(tjd_ut, ipl, "swe_calc_pctr")

    ts = get_timescale()
    t = ts.ut1_jd(tjd_ut)
    try:
        return _calc_body_pctr(t, ipl, iplctr, iflag)
    except SkyfieldRangeError as e:
        raise _wrap_ephemeris_range_error(e, tjd_ut, ipl) from e


def _calc_body_pctr(
    t, ipl: int, iplctr: int, iflag: int
) -> Tuple[Tuple[float, float, float, float, float, float], int]:
    """
    Calculate planet-centric position (internal function).

    Calculates the position of target body (ipl) as seen from center body (iplctr).
    Uses vector subtraction: position = target_SSB - observer_SSB.

    Args:
        t: Skyfield Time object (UT1 or TT)
        ipl: Target planet/body ID
        iplctr: Observer/center planet ID
        iflag: Calculation flags (SEFLG_SPEED, etc.)

    Returns:
        Tuple of (position_tuple, flags)
    """
    from skyfield.framelib import ecliptic_frame
    from skyfield.positionlib import ICRF

    planets = get_planets()

    # Validate that both bodies are in _PLANET_MAP (standard planets only for now)
    if ipl not in _PLANET_MAP:
        # Target not supported - raise clear error
        from .exceptions import UnknownBodyError

        raise UnknownBodyError(
            message=(
                f"Unknown target body ID {ipl} for planet-centric calculation. "
                f"swe_calc_pctr() only supports standard planets (Sun=0 through Earth=14). "
                f"See libephemeris.constants for body ID constants."
            ),
            body_id=ipl,
        )

    if iplctr not in _PLANET_MAP:
        # Observer not supported - raise clear error
        from .exceptions import UnknownBodyError

        raise UnknownBodyError(
            message=(
                f"Unknown observer/center body ID {iplctr} for planet-centric calculation. "
                f"swe_calc_pctr() only supports standard planets (Sun=0 through Earth=14) "
                f"as the observer. See libephemeris.constants for body ID constants."
            ),
            body_id=iplctr,
        )

    target_name = _PLANET_MAP[ipl]
    observer_name = _PLANET_MAP[iplctr]

    # Use get_planet_target() to get planet center (with COB correction) for gas giants
    # This ensures we use planet center NAIF IDs (599, 699, 799, 899) rather than
    # barycenter IDs (5, 6, 7, 8), providing sub-arcsecond positional accuracy
    target = get_planet_target(planets, target_name)
    observer = get_planet_target(planets, observer_name)

    # Helper function to get position vector at time t_
    def get_vector(t_):
        # Both positions relative to SSB (Solar System Barycenter)
        tgt_pos = target.at(t_).position.au
        tgt_vel = target.at(t_).velocity.au_per_d
        obs_pos = observer.at(t_).position.au
        obs_vel = observer.at(t_).velocity.au_per_d

        # Target position relative to observer
        p_ = tgt_pos - obs_pos
        v_ = tgt_vel - obs_vel
        return p_, v_

    # Get position vector
    p, v = get_vector(t)

    # Create position object for coordinate conversion
    # Center doesn't matter for coordinate conversion, just for documentation
    pos = ICRF(p, v, t=t, center=399)

    # Extract coordinates based on flags
    is_equatorial = bool(iflag & SEFLG_EQUATORIAL)
    is_sidereal = bool(iflag & SEFLG_SIDEREAL)

    p1, p2, p3 = 0.0, 0.0, 0.0
    dp1, dp2, dp3 = 0.0, 0.0, 0.0

    if is_equatorial:
        # Equatorial coordinates (RA/Dec)
        if iflag & SEFLG_J2000:
            ra, dec, dist = pos.radec()
        else:
            ra, dec, dist = pos.radec(epoch="date")
        p1 = ra.hours * 15.0  # Convert hours to degrees
        p2 = dec.degrees
        p3 = dist.au
    else:
        # Ecliptic coordinates (default)
        if iflag & SEFLG_J2000:
            # J2000 ecliptic frame
            eps_j2000 = 23.4392911  # Mean obliquity at J2000.0
            x, y, z = pos.position.au
            eps_rad = math.radians(eps_j2000)
            ce = math.cos(eps_rad)
            se = math.sin(eps_rad)

            xe = x
            ye = y * ce + z * se
            ze = -y * se + z * ce

            dist = math.sqrt(xe * xe + ye * ye + ze * ze)
            lon = math.degrees(math.atan2(ye, xe)) % 360.0
            lat = math.degrees(math.asin(ze / dist)) if dist > 0 else 0.0

            p1, p2, p3 = lon, lat, dist
        else:
            # Ecliptic of date
            lat_, lon_, dist_ = pos.frame_latlon(ecliptic_frame)
            p1 = lon_.degrees
            p2 = lat_.degrees
            p3 = dist_.au

    # Calculate speed using central difference numerical differentiation if requested
    # Central difference: f'(x) ≈ [f(x+h) - f(x-h)] / (2h) - error O(h²)
    dt = 1.0 / 86400.0  # 1 second timestep

    if iflag & SEFLG_SPEED:
        ts_inner = get_timescale()
        t_prev = ts_inner.tt_jd(t.tt - dt)
        t_next = ts_inner.tt_jd(t.tt + dt)

        # Get positions at t - dt and t + dt using the same method (without speed or sidereal)
        flags_no_speed_no_sidereal = (iflag & ~SEFLG_SPEED) & ~SEFLG_SIDEREAL
        result_prev, _ = _calc_body_pctr(
            t_prev, ipl, iplctr, flags_no_speed_no_sidereal
        )
        result_next, _ = _calc_body_pctr(
            t_next, ipl, iplctr, flags_no_speed_no_sidereal
        )
        p1_prev, p2_prev, p3_prev = result_prev[0], result_prev[1], result_prev[2]
        p1_next, p2_next, p3_next = result_next[0], result_next[1], result_next[2]

        # Calculate derivatives using central difference
        dp1 = (p1_next - p1_prev) / (2.0 * dt)
        dp2 = (p2_next - p2_prev) / (2.0 * dt)
        dp3 = (p3_next - p3_prev) / (2.0 * dt)

        # Handle longitude wrap-around
        if dp1 > 9000:
            dp1 -= 360.0 / (2.0 * dt)
        elif dp1 < -9000:
            dp1 += 360.0 / (2.0 * dt)

    # Apply sidereal offset if requested (ecliptic only)
    # Note: We use TRUE ayanamsha (mean + nutation) for planet positions,
    # as is standard practice. get_ayanamsa_ut() returns mean ayanamsha.
    if is_sidereal and not is_equatorial:
        ayanamsa = _get_true_ayanamsa(t.ut1)
        p1 = (p1 - ayanamsa) % 360.0

        # Correct velocity for ayanamsha rate if speed was calculated
        if iflag & SEFLG_SPEED:
            ayanamsa_prev = _get_true_ayanamsa(t.ut1 - dt)
            ayanamsa_next = _get_true_ayanamsa(t.ut1 + dt)
            da = (ayanamsa_next - ayanamsa_prev) / (2.0 * dt)
            dp1 -= da

    return _to_native_floats((p1, p2, p3, dp1, dp2, dp3)), iflag


class NutationFallbackWarning(UserWarning):
    """Warning for degraded nutation precision (legacy, kept for API compatibility).

    .. deprecated::
        This warning class is retained for backward API compatibility but is
        no longer issued in normal operation. LibEphemeris now uses pyerfa
        (IAU 2006/2000A, ~0.01-0.05 mas) as a required dependency for all
        nutation calculations.

    See Also:
        get_nutation_model: Check which nutation model is currently active
    """

    pass


def get_nutation_model() -> dict:
    """Check which nutation model is currently active.

    LibEphemeris uses the IAU 2006/2000A nutation model via pyerfa
    (erfa.nut06a) for all nutation calculations. This provides the
    highest precision currently adopted by the IAU (~0.01-0.05 mas).

    Returns:
        dict: A dictionary containing:
            - ``model`` (str): "IAU2006_2000A" (pyerfa erfa.nut06a)
            - ``terms`` (int): Number of terms (1365+ lunisolar+planetary)
            - ``precision`` (str): Expected precision ("~0.01-0.05 mas")
            - ``source`` (str): "pyerfa" (the underlying C library)
            - ``skyfield_available`` (bool): Always True (kept for backward compatibility)

    Examples:
        >>> import libephemeris as eph
        >>> info = eph.get_nutation_model()
        >>> print(f"Model: {info['model']}, precision: {info['precision']}")
    """
    return {
        "model": "IAU2006_2000A",
        "terms": 1365,
        "precision": "~0.01-0.05 mas",
        "source": "pyerfa",
        # Backward compatibility: Skyfield is always available (required dep)
        "skyfield_available": True,
    }


def _calc_nutation_obliquity(
    jd: float, iflag: int
) -> Tuple[Tuple[float, float, float, float, float, float], int]:
    """
    Calculate nutation and obliquity data for SE_ECL_NUT (-1).

    Uses the IAU 2006/2000A nutation model via pyerfa (erfa.nut06a)
    and IAU 2006 mean obliquity via erfa.obl06() for maximum precision.

    Args:
        jd: Julian Day in UT
        iflag: Calculation flags (not used for nutation)

    Returns:
        Tuple containing:
            - Data tuple: (true_obliquity, mean_obliquity, nutation_longitude, nutation_obliquity, 0, 0)
            - Return flag
    """
    import math
    from .state import get_timescale

    ts = get_timescale()
    t = ts.ut1_jd(jd)

    # Calculate Julian centuries from J2000.0
    T = (jd - 2451545.0) / 36525.0

    # Mean obliquity of the ecliptic (IAU 2006 via pyerfa)
    mean_obliquity = math.degrees(erfa.obl06(2451545.0, t.tt - 2451545.0))

    # Nutation IAU 2006/2000A via pyerfa (~0.01-0.05 mas precision)
    dpsi_rad, deps_rad = erfa.nut06a(2451545.0, t.tt - 2451545.0)
    delta_psi = math.degrees(dpsi_rad)
    delta_eps = math.degrees(deps_rad)

    # True obliquity = mean obliquity + nutation in obliquity
    true_obliquity = mean_obliquity + delta_eps

    # Return format: (true_obliquity, mean_obliquity, nutation_lon, nutation_obl, 0, 0)
    return (true_obliquity, mean_obliquity, delta_psi, delta_eps, 0.0, 0.0), iflag


def _maybe_equatorial_convert(result: tuple, jd_tt: float, iflag: int) -> tuple:
    """Convert ecliptic coordinates to equatorial if SEFLG_EQUATORIAL is set.

    For bodies computed in ecliptic coordinates (lunar nodes, Lilith, etc.),
    this applies the ecliptic-to-equatorial transformation using the true
    obliquity of the ecliptic at the given date.

    When SEFLG_J2000 is set, precesses from date to J2000.0 before any
    equatorial conversion.

    Args:
        result: 6-tuple of (lon, lat, dist, dlon, dlat, ddist) in ecliptic coords
        jd_tt: Julian Day in Terrestrial Time (for obliquity calculation)
        iflag: Calculation flags bitmask

    Returns:
        If SEFLG_EQUATORIAL is set: transformed (RA, Dec, dist, dRA, dDec, ddist)
        If SEFLG_J2000 is set: precessed coordinates in J2000 frame
        Otherwise: original result unchanged
    """
    lon, lat, dist, dlon, dlat, ddist = result

    # Apply precession from date to J2000 if requested
    if iflag & SEFLG_J2000:
        from .astrometry import _precess_ecliptic

        J2000 = 2451545.0
        lon, lat = _precess_ecliptic(lon, lat, jd_tt, J2000)

    if not (iflag & SEFLG_EQUATORIAL):
        return (lon, lat, dist, dlon, dlat, ddist)

    from .cache import get_true_obliquity
    from .utils import cotrans_sp

    # For J2000 frame, use J2000 obliquity; otherwise use obliquity of date
    if iflag & SEFLG_J2000:
        eps = 23.4392911  # Mean obliquity at J2000.0
    else:
        eps = get_true_obliquity(jd_tt)

    coord = (lon, lat, dist)
    speed = (dlon, dlat, ddist)
    # Negative obliquity = ecliptic → equatorial (swe.cotrans convention)
    new_coord, new_speed = cotrans_sp(coord, speed, -eps)
    return (
        new_coord[0],
        new_coord[1],
        new_coord[2],
        new_speed[0],
        new_speed[1],
        new_speed[2],
    )


def _keplerian_position_at(
    jd_tt: float, ipl: int, iflag: int, planets_dict
) -> Tuple[float, float, float]:
    """Compute ecliptic position of a minor body via Keplerian fallback.

    Returns (lon, lat, dist) in ecliptic coordinates, either heliocentric
    or geocentric depending on iflag.

    Args:
        jd_tt: Julian Day in Terrestrial Time.
        ipl: Minor body ID (SE_CHIRON, SE_CERES, etc.).
        iflag: Calculation flags (SEFLG_HELCTR checked).
        planets_dict: Skyfield planets dict from get_planets().

    Returns:
        Tuple of (longitude_deg, latitude_deg, distance_au).
    """
    from . import minor_bodies
    from .state import get_timescale

    lon_hel, lat_hel, r_hel = minor_bodies.calc_minor_body_heliocentric(ipl, jd_tt)

    if iflag & SEFLG_HELCTR:
        return lon_hel, lat_hel, r_hel

    # Convert heliocentric ecliptic spherical to Cartesian
    lon_rad = math.radians(lon_hel)
    lat_rad = math.radians(lat_hel)
    x_hel_ecl = r_hel * math.cos(lat_rad) * math.cos(lon_rad)
    y_hel_ecl = r_hel * math.cos(lat_rad) * math.sin(lon_rad)
    z_hel_ecl = r_hel * math.sin(lat_rad)

    # Get Earth position in ecliptic frame
    ts = get_timescale()
    t = ts.tt_jd(jd_tt)
    earth = planets_dict["earth"]
    sun = planets_dict["sun"]
    earth_helio = sun.at(t).observe(earth)
    earth_xyz_ecl = earth_helio.frame_xyz(ecliptic_frame).au

    # Geocentric = minor body heliocentric - Earth heliocentric
    x_geo_ecl = x_hel_ecl - earth_xyz_ecl[0]
    y_geo_ecl = y_hel_ecl - earth_xyz_ecl[1]
    z_geo_ecl = z_hel_ecl - earth_xyz_ecl[2]

    # Convert geocentric Cartesian back to spherical
    r_geo = math.sqrt(x_geo_ecl**2 + y_geo_ecl**2 + z_geo_ecl**2)
    lon = math.degrees(math.atan2(y_geo_ecl, x_geo_ecl)) % 360.0
    lat = math.degrees(math.asin(z_geo_ecl / r_geo)) if r_geo > 0 else 0.0

    return lon, lat, r_geo


def _assist_position_at(
    jd_tt: float, ipl: int, iflag: int, planets_dict
) -> Tuple[float, float, float]:
    """Compute ecliptic position of a minor body via ASSIST N-body integration.

    Returns (lon, lat, dist) in ecliptic coordinates, either heliocentric
    or geocentric depending on iflag.

    Args:
        jd_tt: Julian Day in Terrestrial Time.
        ipl: Minor body ID (SE_CHIRON, SE_CERES, etc.).
        iflag: Calculation flags (SEFLG_HELCTR checked).
        planets_dict: Skyfield planets dict from get_planets().

    Returns:
        Tuple of (longitude_deg, latitude_deg, distance_au).

    Raises:
        ImportError: If ASSIST/REBOUND not installed.
        FileNotFoundError: If ephemeris data files not found.
    """
    from . import minor_bodies
    from .rebound_integration import propagate_orbit_assist, AssistEphemConfig
    from .state import get_timescale

    elements = minor_bodies.MINOR_BODY_ELEMENTS[ipl]
    jd_start = elements.epoch

    result = propagate_orbit_assist(elements, jd_start, jd_tt)

    lon_hel = result.ecliptic_lon
    lat_hel = result.ecliptic_lat
    r_hel = result.distance

    if iflag & SEFLG_HELCTR:
        return lon_hel, lat_hel, r_hel

    ts = get_timescale()
    t = ts.tt_jd(jd_tt)
    sun = planets_dict["sun"]
    earth = planets_dict["earth"]
    earth_helio = sun.at(t).observe(earth)
    earth_xyz_ecl = earth_helio.frame_xyz(ecliptic_frame).au

    lon_rad = math.radians(lon_hel)
    lat_rad = math.radians(lat_hel)
    x_hel_ecl = r_hel * math.cos(lat_rad) * math.cos(lon_rad)
    y_hel_ecl = r_hel * math.cos(lat_rad) * math.sin(lon_rad)
    z_hel_ecl = r_hel * math.sin(lat_rad)

    x_geo_ecl = x_hel_ecl - earth_xyz_ecl[0]
    y_geo_ecl = y_hel_ecl - earth_xyz_ecl[1]
    z_geo_ecl = z_hel_ecl - earth_xyz_ecl[2]

    r_geo = math.sqrt(x_geo_ecl**2 + y_geo_ecl**2 + z_geo_ecl**2)
    lon = math.degrees(math.atan2(y_geo_ecl, x_geo_ecl)) % 360.0
    lat = math.degrees(math.asin(z_geo_ecl / r_geo)) if r_geo > 0 else 0.0

    return lon, lat, r_geo


def _calc_body(
    t, ipl: int, iflag: int
) -> Tuple[Tuple[float, float, float, float, float, float], int]:
    """
    Calculate position of any celestial body or point (internal dispatcher).

    This is the core calculation function that routes requests to appropriate
    sub-modules based on body type. Supports all reference API body types.

    Supported body types:
        - Classical planets (Sun, Moon, Mercury-Pluto) via JPL DE421 ephemeris
        - Lunar nodes (Mean/True North/South) via lunar.py
        - Lilith/Lunar apogee (Mean/Osculating) via lunar.py
        - Minor bodies (asteroids, TNOs) via minor_bodies.py with rigorous geocentric conversion
        - Fixed stars (Regulus, Spica) via fixed_stars.py
        - Astrological angles (ASC, MC, Vertex, etc.) via angles.py
        - Arabic parts (Fortune, Spirit, etc.) via arabic_parts.py

    Args:
        t: Skyfield Time object (UT1 or TT)
        ipl: Planet/body ID (SE_SUN, SE_MOON, SE_MARS, etc.)
        iflag: Calculation flags bitmask (SEFLG_SPEED, SEFLG_HELCTR, etc.)

    Returns:
        Tuple containing:
            - Position tuple: (lon, lat, dist, speed_lon, speed_lat, speed_dist)
            - Return flag: iflag value on success

    Coordinate Systems:
        - Ecliptic (default): Longitude/Latitude relative to ecliptic plane
        - Equatorial (SEFLG_EQUATORIAL): Right Ascension/Declination
        - Heliocentric (SEFLG_HELCTR): Sun-centered coordinates
        - Topocentric (SEFLG_TOPOCTR): Observer location on Earth surface
        - Sidereal (SEFLG_SIDEREAL): Fixed zodiac (requires swe_set_sid_mode)

    Precision:
        - Minor body geocentric conversion uses Skyfield's frame transformations
        - Properly handles precession, nutation, and true obliquity of date
    """
    from . import (
        lunar,
        minor_bodies,
        fixed_stars,
        angles,
        arabic_parts,
        planetary_moons,
    )
    from .state import get_angles_cache

    planets = get_planets()

    # Handle planetary moons (Galilean moons, Titan, etc.)
    if planetary_moons.is_planetary_moon(ipl):
        result = planetary_moons.calc_moon_position(t, ipl, iflag)
        if result is not None:
            result = _maybe_equatorial_convert(result, t.tt, iflag)
            return result, iflag
        # If moon not registered, return zeros (body not available)
        return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0), iflag

    # Handle lunar nodes (Mean/True North/South)
    if ipl in [SE_MEAN_NODE, SE_TRUE_NODE]:
        jd_tt = t.tt
        is_sidereal = bool(iflag & SEFLG_SIDEREAL)
        if ipl == SE_MEAN_NODE:
            lon = lunar.calc_mean_lunar_node(jd_tt)
            # Calculate velocity via central difference numerical differentiation
            # Using ±0.5 days to capture any slow variations in the mean motion.
            dlon = 0.0
            if iflag & SEFLG_SPEED:
                dt = 0.5  # 0.5 days for consistency with other lunar velocity calcs
                lon_prev = lunar.calc_mean_lunar_node(jd_tt - dt)
                lon_next = lunar.calc_mean_lunar_node(jd_tt + dt)
                # Handle longitude wrap-around before computing velocity
                lon_diff = lon_next - lon_prev
                if lon_diff > 180:
                    lon_diff -= 360.0
                elif lon_diff < -180:
                    lon_diff += 360.0
                dlon = lon_diff / (2.0 * dt)
            # Apply sidereal correction if requested (but not for equatorial output)
            # Sidereal correction is ignored when outputting equatorial coords
            if is_sidereal and not (iflag & SEFLG_EQUATORIAL):
                ayanamsa = _get_true_ayanamsa(t.ut1)
                lon = (lon - ayanamsa) % 360.0
                # Correct velocity for ayanamsha rate
                if iflag & SEFLG_SPEED:
                    dt_aya = 1.0 / 86400.0
                    ayanamsa_prev = _get_true_ayanamsa(t.ut1 - dt_aya)
                    ayanamsa_next = _get_true_ayanamsa(t.ut1 + dt_aya)
                    da = (ayanamsa_next - ayanamsa_prev) / (2.0 * dt_aya)
                    dlon -= da
            result = (lon, 0.0, 0.0, dlon, 0.0, 0.0)
            result = _maybe_equatorial_convert(result, jd_tt, iflag)
            return _to_native_floats(result), iflag
        else:  # SE_TRUE_NODE
            lon, lat, dist = lunar.calc_true_lunar_node(jd_tt)
            # Calculate velocity via central difference numerical differentiation
            # Using ±0.5 days to capture perturbation effects that a 1-second
            # step would miss, ensuring velocity reflects the actual rate of
            # change including all periodic terms from ELP2000-82B theory.
            dlon, dlat, ddist = 0.0, 0.0, 0.0
            if iflag & SEFLG_SPEED:
                dt = 0.5  # 0.5 days for perturbation-corrected velocity
                try:
                    lon_prev, lat_prev, dist_prev = lunar.calc_true_lunar_node(
                        jd_tt - dt
                    )
                    lon_next, lat_next, dist_next = lunar.calc_true_lunar_node(
                        jd_tt + dt
                    )
                    # Handle longitude wrap-around before computing velocity
                    lon_diff = lon_next - lon_prev
                    if lon_diff > 180:
                        lon_diff -= 360.0
                    elif lon_diff < -180:
                        lon_diff += 360.0
                    dlon = lon_diff / (2.0 * dt)
                    dlat = (lat_next - lat_prev) / (2.0 * dt)
                    ddist = (dist_next - dist_prev) / (2.0 * dt)
                except Exception:
                    # At ephemeris boundaries, speed calculation may fail
                    # Return 0 for speed components
                    pass
            # Apply sidereal correction if requested (but not for equatorial output)
            # Sidereal correction is ignored when outputting equatorial coords
            if is_sidereal and not (iflag & SEFLG_EQUATORIAL):
                ayanamsa = _get_true_ayanamsa(t.ut1)
                lon = (lon - ayanamsa) % 360.0
                # Correct velocity for ayanamsha rate
                if iflag & SEFLG_SPEED:
                    dt_aya = 1.0 / 86400.0
                    ayanamsa_prev = _get_true_ayanamsa(t.ut1 - dt_aya)
                    ayanamsa_next = _get_true_ayanamsa(t.ut1 + dt_aya)
                    da = (ayanamsa_next - ayanamsa_prev) / (2.0 * dt_aya)
                    dlon -= da
            result = (lon, lat, dist, dlon, dlat, ddist)
            result = _maybe_equatorial_convert(result, jd_tt, iflag)
            return _to_native_floats(result), iflag

    # South nodes are 180° from north nodes
    if ipl in [-SE_MEAN_NODE, -SE_TRUE_NODE]:
        north_ipl = abs(ipl)
        result, flags = _calc_body(t, north_ipl, iflag)
        south_lon = (result[0] + 180.0) % 360.0
        return (
            south_lon,
            -result[1],
            result[2],
            result[3],
            -result[4],
            result[5],
        ), flags

    # Handle Lilith (Mean/Osculating Apogee)
    if ipl in [SE_MEAN_APOG, SE_OSCU_APOG]:
        jd_tt = t.tt
        is_sidereal = bool(iflag & SEFLG_SIDEREAL)
        if ipl == SE_MEAN_APOG:
            lon, lat = lunar.calc_mean_lilith_with_latitude(jd_tt)
            # Calculate velocity via central difference numerical differentiation
            # Using ±0.5 days to capture perturbation effects that a 1-second
            # step would miss, ensuring velocity reflects the actual rate of
            # change including all periodic correction terms.
            dlon, dlat = 0.0, 0.0
            if iflag & SEFLG_SPEED:
                dt = 0.5  # 0.5 days for perturbation-corrected velocity
                lon_prev, lat_prev = lunar.calc_mean_lilith_with_latitude(jd_tt - dt)
                lon_next, lat_next = lunar.calc_mean_lilith_with_latitude(jd_tt + dt)
                # Handle longitude wrap-around before computing velocity
                lon_diff = lon_next - lon_prev
                if lon_diff > 180:
                    lon_diff -= 360.0
                elif lon_diff < -180:
                    lon_diff += 360.0
                dlon = lon_diff / (2.0 * dt)
                dlat = (lat_next - lat_prev) / (2.0 * dt)
            # Apply sidereal correction if requested (but not for equatorial output)
            # Sidereal correction is ignored when outputting equatorial coords
            if is_sidereal and not (iflag & SEFLG_EQUATORIAL):
                ayanamsa = _get_true_ayanamsa(t.ut1)
                lon = (lon - ayanamsa) % 360.0
                if iflag & SEFLG_SPEED:
                    dt_aya = 1.0 / 86400.0
                    ayanamsa_prev = _get_true_ayanamsa(t.ut1 - dt_aya)
                    ayanamsa_next = _get_true_ayanamsa(t.ut1 + dt_aya)
                    da = (ayanamsa_next - ayanamsa_prev) / (2.0 * dt_aya)
                    dlon -= da
            result = (lon, lat, 0.0, dlon, dlat, 0.0)
            result = _maybe_equatorial_convert(result, jd_tt, iflag)
            return _to_native_floats(result), iflag
        else:  # SE_OSCU_APOG
            lon, lat, dist = lunar.calc_true_lilith(jd_tt)
            # Calculate velocity via central difference numerical differentiation
            # Using ±0.5 days to capture perturbation effects that a 1-second
            # step would miss, ensuring velocity reflects the actual rate of
            # change including all periodic terms from orbital mechanics.
            dlon, dlat, ddist = 0.0, 0.0, 0.0
            if iflag & SEFLG_SPEED:
                dt = 0.5  # 0.5 days for perturbation-corrected velocity
                try:
                    lon_prev, lat_prev, dist_prev = lunar.calc_true_lilith(jd_tt - dt)
                    lon_next, lat_next, dist_next = lunar.calc_true_lilith(jd_tt + dt)
                    # Handle longitude wrap-around before computing velocity
                    lon_diff = lon_next - lon_prev
                    if lon_diff > 180:
                        lon_diff -= 360.0
                    elif lon_diff < -180:
                        lon_diff += 360.0
                    dlon = lon_diff / (2.0 * dt)
                    dlat = (lat_next - lat_prev) / (2.0 * dt)
                    ddist = (dist_next - dist_prev) / (2.0 * dt)
                except Exception:
                    # At ephemeris boundaries, speed calculation may fail
                    # Return 0 for speed components
                    pass
            # Apply sidereal correction if requested (but not for equatorial output)
            # Sidereal correction is ignored when outputting equatorial coords
            if is_sidereal and not (iflag & SEFLG_EQUATORIAL):
                ayanamsa = _get_true_ayanamsa(t.ut1)
                lon = (lon - ayanamsa) % 360.0
                if iflag & SEFLG_SPEED:
                    dt_aya = 1.0 / 86400.0
                    ayanamsa_prev = _get_true_ayanamsa(t.ut1 - dt_aya)
                    ayanamsa_next = _get_true_ayanamsa(t.ut1 + dt_aya)
                    da = (ayanamsa_next - ayanamsa_prev) / (2.0 * dt_aya)
                    dlon -= da
            result = (lon, lat, dist, dlon, dlat, ddist)
            result = _maybe_equatorial_convert(result, jd_tt, iflag)
            return _to_native_floats(result), iflag

    # Handle Interpolated Apogee/Perigee
    if ipl in [SE_INTP_APOG, SE_INTP_PERG]:
        jd_tt = t.tt
        is_sidereal = bool(iflag & SEFLG_SIDEREAL)
        if ipl == SE_INTP_APOG:
            lon, lat, dist = lunar.calc_interpolated_apogee(jd_tt)
        else:  # SE_INTP_PERG
            lon, lat, dist = lunar.calc_interpolated_perigee(jd_tt)
        # Calculate velocity via central difference numerical differentiation
        # Using ±0.5 days to capture perturbation effects that a 1-second
        # step would miss, ensuring velocity reflects the actual rate of
        # change including all periodic terms.
        dlon, dlat, ddist = 0.0, 0.0, 0.0
        if iflag & SEFLG_SPEED:
            dt = 0.5  # 0.5 days for perturbation-corrected velocity
            if ipl == SE_INTP_APOG:
                lon_prev, lat_prev, dist_prev = lunar.calc_interpolated_apogee(
                    jd_tt - dt
                )
                lon_next, lat_next, dist_next = lunar.calc_interpolated_apogee(
                    jd_tt + dt
                )
            else:
                lon_prev, lat_prev, dist_prev = lunar.calc_interpolated_perigee(
                    jd_tt - dt
                )
                lon_next, lat_next, dist_next = lunar.calc_interpolated_perigee(
                    jd_tt + dt
                )
            # Handle longitude wrap-around before computing velocity
            lon_diff = lon_next - lon_prev
            if lon_diff > 180:
                lon_diff -= 360.0
            elif lon_diff < -180:
                lon_diff += 360.0
            dlon = lon_diff / (2.0 * dt)
            dlat = (lat_next - lat_prev) / (2.0 * dt)
            ddist = (dist_next - dist_prev) / (2.0 * dt)
        # Apply sidereal correction if requested (but not for equatorial output)
        # Swiss Ephemeris ignores sidereal flag when outputting equatorial coords
        if is_sidereal and not (iflag & SEFLG_EQUATORIAL):
            ayanamsa = _get_true_ayanamsa(t.ut1)
            lon = (lon - ayanamsa) % 360.0
            if iflag & SEFLG_SPEED:
                dt_aya = 1.0 / 86400.0
                ayanamsa_prev = _get_true_ayanamsa(t.ut1 - dt_aya)
                ayanamsa_next = _get_true_ayanamsa(t.ut1 + dt_aya)
                da = (ayanamsa_next - ayanamsa_prev) / (2.0 * dt_aya)
                dlon -= da
        result = (lon, lat, dist, dlon, dlat, ddist)
        result = _maybe_equatorial_convert(result, jd_tt, iflag)
        return _to_native_floats(result), iflag

    # Handle Uranian planets (Hamburg School hypothetical bodies, IDs 40-47)
    if SE_CUPIDO <= ipl <= SE_POSEIDON:
        from . import hypothetical

        jd_tt = t.tt
        # Use calc_uranian_planet() which uses Keplerian orbital elements
        # for Keplerian orbital element-based Uranian planet calculations
        pos = hypothetical.calc_uranian_planet(ipl, jd_tt)
        pos = _maybe_equatorial_convert(pos, jd_tt, iflag)
        return _to_native_floats(pos), iflag

    # Handle Transpluto (Isis) - hypothetical trans-Plutonian planet (ID 48)
    if ipl == SE_ISIS:
        from . import hypothetical

        jd_tt = t.tt
        # Use calc_transpluto() which uses Keplerian orbital elements
        pos = hypothetical.calc_transpluto(jd_tt)
        pos = _maybe_equatorial_convert(pos, jd_tt, iflag)
        return _to_native_floats(pos), iflag

    # Handle minor bodies (asteroids and TNOs)
    if ipl in minor_bodies.MINOR_BODY_ELEMENTS:
        # Try SPK kernel first (high precision)
        from . import spk
        from .state import get_auto_spk_download, get_strict_precision
        from .exceptions import SPKRequiredError
        from .constants import SPK_BODY_NAME_MAP

        spk_result = spk.calc_spk_body_position(t, ipl, iflag)
        if spk_result is not None:
            spk_result = _maybe_equatorial_convert(spk_result, t.tt, iflag)
            return _to_native_floats(spk_result), iflag

        # Try automatic SPK download if enabled and body not registered.
        # Uses direct HTTP to JPL Horizons (no astroquery dependency).
        if get_auto_spk_download():
            try:
                spk_result = _try_auto_spk_download(t, ipl, iflag)
                if spk_result is not None:
                    spk_result = _maybe_equatorial_convert(spk_result, t.tt, iflag)
                    return _to_native_floats(spk_result), iflag
            except Exception:
                # If auto-download fails, continue to strict precision check
                pass

        # In strict precision mode, require SPK for all downloadable bodies.
        # All bodies in SPK_BODY_NAME_MAP can be downloaded from JPL Horizons.
        if get_strict_precision() and ipl in SPK_BODY_NAME_MAP:
            horizons_id, _ = SPK_BODY_NAME_MAP[ipl]
            body_name = spk._get_body_name(ipl) or str(ipl)
            raise SPKRequiredError.for_body(ipl, body_name, horizons_id)

        jd_tt = t.tt

        # Try ASSIST N-body integration fallback if available
        try:
            from .rebound_integration import check_assist_available

            if check_assist_available():
                lon, lat, dist = _assist_position_at(jd_tt, ipl, iflag, planets)

                speed_lon = 0.0
                speed_lat = 0.0
                speed_dist = 0.0
                if iflag & SEFLG_SPEED:
                    dt = 1.0 / 86400.0
                    lon_prev, lat_prev, dist_prev = _assist_position_at(
                        jd_tt - dt, ipl, iflag, planets
                    )
                    lon_next, lat_next, dist_next = _assist_position_at(
                        jd_tt + dt, ipl, iflag, planets
                    )
                    speed_lon = (lon_next - lon_prev) / (2.0 * dt)
                    speed_lat = (lat_next - lat_prev) / (2.0 * dt)
                    speed_dist = (dist_next - dist_prev) / (2.0 * dt)

                    if speed_lon > 180.0 / (2.0 * dt):
                        speed_lon -= 360.0 / (2.0 * dt)
                    if speed_lon < -180.0 / (2.0 * dt):
                        speed_lon += 360.0 / (2.0 * dt)

                return _to_native_floats(
                    _maybe_equatorial_convert(
                        (lon, lat, dist, speed_lon, speed_lat, speed_dist), jd_tt, iflag
                    )
                ), iflag
        except Exception:
            pass

        # Keplerian as last resort
        lon, lat, dist = _keplerian_position_at(jd_tt, ipl, iflag, planets)

        speed_lon = 0.0
        speed_lat = 0.0
        speed_dist = 0.0
        if iflag & SEFLG_SPEED:
            dt = 1.0 / 86400.0
            lon_prev, lat_prev, dist_prev = _keplerian_position_at(
                jd_tt - dt, ipl, iflag, planets
            )
            lon_next, lat_next, dist_next = _keplerian_position_at(
                jd_tt + dt, ipl, iflag, planets
            )
            speed_lon = (lon_next - lon_prev) / (2.0 * dt)
            speed_lat = (lat_next - lat_prev) / (2.0 * dt)
            speed_dist = (dist_next - dist_prev) / (2.0 * dt)

            if speed_lon > 180.0 / (2.0 * dt):
                speed_lon -= 360.0 / (2.0 * dt)
            if speed_lon < -180.0 / (2.0 * dt):
                speed_lon += 360.0 / (2.0 * dt)

        return _to_native_floats(
            _maybe_equatorial_convert(
                (lon, lat, dist, speed_lon, speed_lat, speed_dist), jd_tt, iflag
            )
        ), iflag

    # Handle fixed stars
    if ipl in fixed_stars.FIXED_STARS:
        jd_tt = t.tt
        lon, lat, dist = fixed_stars.calc_fixed_star_position(ipl, jd_tt)
        result = (lon, lat, dist, 0.0, 0.0, 0.0)
        result = _maybe_equatorial_convert(result, jd_tt, iflag)
        return _to_native_floats(result), iflag

    # Handle astrological angles (requires observer location)
    if SE_ANGLE_OFFSET <= ipl < SE_ARABIC_OFFSET:
        topo = get_topo()
        if topo is None:
            raise ValueError(
                "Angles require observer location. Call swe_set_topo() first."
            )

        # Extract lat/lon from topo
        lat = topo.latitude.degrees
        lon = topo.longitude.degrees
        jd_ut = t.ut1

        angle_val = angles.get_angle_value(ipl, jd_ut, lat, lon)
        return (angle_val, 0.0, 0.0, 0.0, 0.0, 0.0), iflag

    # Handle Arabic parts (requires cached planet positions)
    if SE_ARABIC_OFFSET <= ipl < SE_ARABIC_OFFSET + 100:
        cache = get_angles_cache()
        if not cache:
            raise ValueError(
                "Arabic parts require pre-calculated positions. Call swe_calc_angles() first."
            )

        # Map part IDs to calculation functions
        if ipl == SE_PARS_FORTUNAE:
            asc = cache.get("Asc", cache.get("Ascendant", 0))
            sun = cache.get("Sun", 0)
            moon = cache.get("Moon", 0)
            is_diurnal = arabic_parts.is_day_chart(sun, asc)
            lon = arabic_parts.calc_arabic_part_of_fortune(asc, sun, moon, is_diurnal)
        elif ipl == SE_PARS_SPIRITUS:
            asc = cache.get("Asc", cache.get("Ascendant", 0))
            sun = cache.get("Sun", 0)
            moon = cache.get("Moon", 0)
            is_diurnal = arabic_parts.is_day_chart(sun, asc)
            lon = arabic_parts.calc_arabic_part_of_spirit(asc, sun, moon, is_diurnal)
        elif ipl == SE_PARS_AMORIS:
            asc = cache.get("Asc", cache.get("Ascendant", 0))
            venus = cache.get("Venus", 0)
            sun = cache.get("Sun", 0)
            lon = arabic_parts.calc_arabic_part_of_love(asc, venus, sun)
        elif ipl == SE_PARS_FIDEI:
            asc = cache.get("Asc", cache.get("Ascendant", 0))
            mercury = cache.get("Mercury", 0)
            moon = cache.get("Moon", 0)
            lon = arabic_parts.calc_arabic_part_of_faith(asc, mercury, moon)
        else:
            lon = 0.0

        return _to_native_floats((lon, 0.0, 0.0, 0.0, 0.0, 0.0)), iflag

    # Handle standard planets
    if ipl in _PLANET_MAP:
        target_name = _PLANET_MAP[ipl]
        target = get_planet_target(planets, target_name)
    else:
        # Unknown body - raise clear error instead of returning zeros
        from .exceptions import UnknownBodyError

        raise UnknownBodyError(
            message=(
                f"Unknown body ID {ipl}. "
                f"Supported bodies include: standard planets (0-14), lunar nodes (10-11), "
                f"Lilith/apogee (12-13, 21-22), asteroids (15-20), "
                f"Uranian planets (40-47), Transpluto (48), minor bodies (SE_AST_OFFSET+number), "
                f"and fixed stars (SE_FIXSTAR_OFFSET+number). "
                f"See libephemeris.constants for all body ID constants."
            ),
            body_id=ipl,
        )

    # 2. Identify Observer
    observer_topo = get_topo()

    is_barycentric = bool(iflag & SEFLG_BARYCTR)

    if iflag & SEFLG_HELCTR:
        # Heliocentric
        observer = planets["sun"]
        icrf_center = 10  # Sun
    elif is_barycentric:
        # Barycentric - position relative to Solar System Barycenter (SSB)
        # In Skyfield, the SSB is the origin (center=0), so we don't need an observer
        observer = None
        icrf_center = 0  # SSB
    elif (iflag & SEFLG_TOPOCTR) and observer_topo:
        earth = planets["earth"]
        observer = earth + observer_topo
        icrf_center = observer_topo
    else:
        # Geocentric
        observer = planets["earth"]
        icrf_center = 399  # Earth

    # 3. Compute Position
    # Helper to get vector at time t
    def get_vector(t_):
        # Target position relative to SSB
        tgt_pos = target.at(t_).position.au
        tgt_vel = target.at(t_).velocity.au_per_d

        if observer is None:
            # Barycentric: target position is already relative to SSB
            return tgt_pos, tgt_vel

        # Observer relative to SSB
        obs_pos = observer.at(t_).position.au
        obs_vel = observer.at(t_).velocity.au_per_d

        p_ = tgt_pos - obs_pos
        v_ = tgt_vel - obs_vel
        return p_, v_

    if iflag & SEFLG_TRUEPOS:
        # Geometric position (instantaneous)
        p, v = get_vector(t)
        from skyfield.positionlib import ICRF

        pos = ICRF(p, v, t=t, center=icrf_center)
    else:
        # Apparent position
        if (iflag & SEFLG_HELCTR) or (iflag & SEFLG_BARYCTR):
            # For SSB or Heliocentric, we need to apply light-time correction
            # Light-time correction: position shows where object WAS
            # when light left it to reach the observer (Sun for heliocentric)
            import numpy as np

            # Speed of light in AU/day
            C_AU_PER_DAY = 173.1446326847

            # Get initial geometric position
            p, v = get_vector(t)

            # Iterative light-time correction (2-3 iterations is enough)
            for _ in range(3):
                dist = np.sqrt(p[0] ** 2 + p[1] ** 2 + p[2] ** 2)
                light_time = dist / C_AU_PER_DAY
                ts_lt = get_timescale()
                t_retarded = ts_lt.tdb_jd(t.tdb - light_time)
                p, v = get_vector(t_retarded)

            from skyfield.positionlib import ICRF

            pos = ICRF(p, v, t=t, center=icrf_center)
        else:
            if iflag & SEFLG_NOABERR:
                pos = observer.at(t).observe(target)  # Astrometric
            else:
                pos = observer.at(t).observe(target).apparent()  # Apparent

    # 4. Coordinate System & Speeds
    is_equatorial = bool(iflag & SEFLG_EQUATORIAL)
    is_sidereal = bool(iflag & SEFLG_SIDEREAL)

    p1, p2, p3 = 0.0, 0.0, 0.0
    dp1, dp2, dp3 = 0.0, 0.0, 0.0

    # Get position and velocity vectors in AU and AU/day
    # We need them in the correct frame.
    # Skyfield's pos.position.au and pos.velocity.au_per_d are in ICRS (Equatorial J2000).
    # If we want Ecliptic, we need to rotate them.

    # Define rotation matrix or use Skyfield's frame transform
    # Skyfield doesn't easily rotate velocity vectors with frame_latlon.
    # We have to do it manually or use `frame_xyz(frame)`.

    if is_equatorial:
        # Equatorial coordinates (Right Ascension / Declination)
        # Frame options: ICRS (J2000) or True Equator of Date

        if iflag & SEFLG_J2000:
            # ICRS J2000 equatorial coordinates
            # radec() returns J2000 RA/Dec by default for ICRS/GCRS positions
            ra, dec, dist = pos.radec()
            p1 = ra.hours * 15.0
            p2 = dec.degrees
            p3 = dist.au

            # Velocities?
            # Skyfield doesn't give RA/Dec rates directly.
            # We can use numerical differentiation if speed is requested.
            if iflag & SEFLG_SPEED:
                # Calculate velocity using central difference for J2000
                dt = 1.0 / 86400.0
                ts_inner = get_timescale()  # Fix: get ts locally

                # Helper to get J2000 RA/Dec at t
                def get_j2000_coord(t_):
                    from skyfield.positionlib import ICRF

                    if (
                        iflag & SEFLG_TRUEPOS
                        or (iflag & SEFLG_BARYCTR)
                        or (iflag & SEFLG_HELCTR)
                    ):
                        p_, v_ = get_vector(t_)
                        pos_ = ICRF(
                            p_,
                            v_,
                            t=t_,
                            center=icrf_center,
                        )
                    else:
                        if iflag & SEFLG_NOABERR:
                            pos_ = observer.at(t_).observe(target)
                        else:
                            pos_ = observer.at(t_).observe(target).apparent()
                    ra_, dec_, dist_ = pos_.radec()
                    return ra_.hours * 15.0, dec_.degrees, dist_.au

                # Central difference: get t-dt and t+dt
                p1_prev, p2_prev, p3_prev = get_j2000_coord(ts_inner.tt_jd(t.tt - dt))
                p1_next, p2_next, p3_next = get_j2000_coord(ts_inner.tt_jd(t.tt + dt))
                dp1 = (p1_next - p1_prev) / (2.0 * dt)
                dp2 = (p2_next - p2_prev) / (2.0 * dt)
                dp3 = (p3_next - p3_prev) / (2.0 * dt)
                if dp1 > 9000:
                    dp1 -= 360 / (2.0 * dt)
                if dp1 < -9000:
                    dp1 += 360 / (2.0 * dt)
        else:
            # True Equator of Date
            # Central difference numerical differentiation for speeds
            # 1 second timestep provides good balance between accuracy and numerical stability
            dt = 1.0 / 86400.0  # 1 second in days

            # Helper to get coord at time t_
            def get_coord(t_):
                from skyfield.positionlib import ICRF

                if (
                    iflag & SEFLG_TRUEPOS
                    or (iflag & SEFLG_BARYCTR)
                    or (iflag & SEFLG_HELCTR)
                ):
                    p_, v_ = get_vector(t_)
                    pos_ = ICRF(
                        p_,
                        v_,
                        t=t_,
                        center=icrf_center,
                    )
                else:
                    if iflag & SEFLG_NOABERR:
                        pos_ = observer.at(t_).observe(target)
                    else:
                        pos_ = observer.at(t_).observe(target).apparent()

                if iflag & SEFLG_J2000:
                    ra_, dec_, dist_ = pos_.radec()
                else:
                    ra_, dec_, dist_ = pos_.radec(epoch="date")
                return ra_.hours * 15.0, dec_.degrees, dist_.au

            p1, p2, p3 = get_coord(t)

            if iflag & SEFLG_SPEED:
                ts = get_timescale()
                # Central difference: get t-dt and t+dt
                p1_prev, p2_prev, p3_prev = get_coord(ts.tt_jd(t.tt - dt))
                p1_next, p2_next, p3_next = get_coord(ts.tt_jd(t.tt + dt))
                dp1 = (p1_next - p1_prev) / (2.0 * dt)
                dp2 = (p2_next - p2_prev) / (2.0 * dt)
                dp3 = (p3_next - p3_prev) / (2.0 * dt)
                # Handle 360 wrap for RA
                if dp1 > 9000:
                    dp1 -= 360 / (2.0 * dt)
                if dp1 < -9000:
                    dp1 += 360 / (2.0 * dt)

    else:
        # Ecliptic (Long/Lat)
        if iflag & SEFLG_J2000:
            # Ecliptic J2000.0 coordinates
            # Manual rotation from ICRS (equatorial) to ecliptic using obliquity
            # Transformation: rotation around X-axis by mean obliquity of J2000.0
            # x_ecl = x_eq
            # y_ecl = y_eq * cos(eps) + z_eq * sin(eps)
            # z_ecl = -y_eq * sin(eps) + z_eq * cos(eps)

            eps_j2000 = 23.4392911  # Mean obliquity at J2000.0 (IAU 1976)
            x, y, z = pos.position.au
            eps_rad = math.radians(eps_j2000)
            ce = math.cos(eps_rad)
            se = math.sin(eps_rad)

            xe = x
            ye = y * ce + z * se
            ze = -y * se + z * ce

            # Convert to spherical
            dist = math.sqrt(xe * xe + ye * ye + ze * ze)
            lon = math.degrees(math.atan2(ye, xe)) % 360.0
            lat = math.degrees(math.asin(ze / dist))

            p1, p2, p3 = lon, lat, dist

        else:
            # Ecliptic of Date
            lat_, lon_, dist_ = pos.frame_latlon(ecliptic_frame)
            p1 = lon_.degrees
            p2 = lat_.degrees
            p3 = dist_.au

    # 4. Speed (Central Difference Numerical Differentiation if requested)
    # Using central differences: f'(x) ≈ [f(x+h) - f(x-h)] / (2h)
    # This has error O(h²) compared to O(h) for forward differences,
    # providing ~100x better precision for the same timestep.
    #
    # For the Moon, we use a larger timestep (7e-5 days = ~6 seconds) which
    # provides optimal velocity precision.
    # This value was empirically determined to minimize the maximum velocity error
    # across a wide range of dates (1900-2100).
    if ipl == SE_MOON:
        dt = 7e-5  # ~6 seconds in days (optimized for Moon velocity precision)
    else:
        dt = 1.0 / 86400.0  # 1 second in days (half-step for central diff)
    dp1, dp2, dp3 = 0.0, 0.0, 0.0

    if iflag & SEFLG_SPEED:
        # Get positions at t - dt and t + dt for central difference
        ts_inner = get_timescale()
        t_prev = ts_inner.tt_jd(t.tt - dt)
        t_next = ts_inner.tt_jd(t.tt + dt)

        # CRITICAL: Remove SIDEREAL flag from recursive call to ensure both positions
        # are in the same frame (tropical) before calculating velocity.
        # We'll apply sidereal conversion to the velocity afterwards.
        flags_no_speed_no_sidereal = (iflag & ~SEFLG_SPEED) & ~SEFLG_SIDEREAL
        result_prev, _ = _calc_body(t_prev, ipl, flags_no_speed_no_sidereal)
        result_next, _ = _calc_body(t_next, ipl, flags_no_speed_no_sidereal)
        p1_prev, p2_prev, p3_prev = result_prev[0], result_prev[1], result_prev[2]
        p1_next, p2_next, p3_next = result_next[0], result_next[1], result_next[2]

        # Calculate derivatives using central difference formula
        # dp/dt = (p(t+dt) - p(t-dt)) / (2*dt)
        dp1 = (p1_next - p1_prev) / (2.0 * dt)
        dp2 = (p2_next - p2_prev) / (2.0 * dt)
        dp3 = (p3_next - p3_prev) / (2.0 * dt)

        # Handle longitude wrap-around for dp1
        # The threshold depends on dt: 180° / (2*dt) in degrees/day
        wrap_threshold = 180.0 / (2.0 * dt)
        if dp1 > wrap_threshold:
            dp1 -= 360.0 / (2.0 * dt)
        elif dp1 < -wrap_threshold:
            dp1 += 360.0 / (2.0 * dt)

    # 5. Sidereal Mode
    # Note: We use TRUE ayanamsha (mean + nutation) for planet positions,
    # as is standard practice. get_ayanamsa_ut() returns mean ayanamsha.
    if is_sidereal and not is_equatorial:
        ayanamsa = _get_true_ayanamsa(t.ut1)
        p1 = (p1 - ayanamsa) % 360.0

        # Correct velocity for ayanamsha rate if speed was calculated
        # Central difference: (f(t+h) - f(t-h)) / (2h) for O(h²) precision
        if iflag & SEFLG_SPEED:
            ayanamsa_prev = _get_true_ayanamsa(t.ut1 - dt)
            ayanamsa_next = _get_true_ayanamsa(t.ut1 + dt)
            da = (ayanamsa_next - ayanamsa_prev) / (2.0 * dt)
            dp1 -= da

    return _to_native_floats((p1, p2, p3, dp1, dp2, dp3)), iflag


def _calc_body_with_context(
    t, ipl: int, iflag: int, ctx
) -> Tuple[Tuple[float, float, float, float, float, float], int]:
    """
    Calculate position using an explicit EphemerisContext (thread-safe).

    This is a context-aware wrapper around the core calculation logic.
    It temporarily sets global state from context, calls _calc_body, then
    restores global state. This allows context-based thread-safe usage while
    reusing the existing calculation code.

    Args:
        t: Skyfield Time object (UT1 or TT)
        ipl: Planet/body ID
        iflag: Calculation flags
        ctx: EphemerisContext instance containing state

    Returns:
        Same as _calc_body: ((lon, lat, dist, dlon, dlat, ddist), retflag)

    Thread Safety:
        This function acquires state._CONTEXT_SWAP_LOCK to ensure that the
        save-set-restore cycle is atomic across threads. Without this lock,
        concurrent calls could interleave and corrupt each other's state.
    """
    from . import state

    with state._CONTEXT_SWAP_LOCK:
        # Save current global state
        old_topo = state._TOPO
        old_sid_mode = state._SIDEREAL_MODE
        old_sid_t0 = state._SIDEREAL_T0
        old_sid_ayan_t0 = state._SIDEREAL_AYAN_T0
        old_angles_cache = state._ANGLES_CACHE

        try:
            # Temporarily set global state from context
            state._TOPO = ctx.topo
            state._SIDEREAL_MODE = ctx.sidereal_mode
            state._SIDEREAL_T0 = ctx.sidereal_t0
            state._SIDEREAL_AYAN_T0 = ctx.sidereal_ayan_t0
            state._ANGLES_CACHE = ctx._angles_cache

            # Use existing calculation logic
            return _calc_body(t, ipl, iflag)
        finally:
            # Restore global state
            state._TOPO = old_topo
            state._SIDEREAL_MODE = old_sid_mode
            state._SIDEREAL_T0 = old_sid_t0
            state._SIDEREAL_AYAN_T0 = old_sid_ayan_t0
            state._ANGLES_CACHE = old_angles_cache


def _calc_body_pctr_with_context(
    t, ipl: int, iplctr: int, iflag: int, ctx
) -> Tuple[Tuple[float, float, float, float, float, float], int]:
    """
    Calculate planet-centric position using an explicit EphemerisContext (thread-safe).

    This is a context-aware wrapper around _calc_body_pctr.

    Args:
        t: Skyfield Time object (UT1 or TT)
        ipl: Target planet/body ID
        iplctr: Observer/center planet ID
        iflag: Calculation flags
        ctx: EphemerisContext instance containing state

    Returns:
        Same as _calc_body_pctr: ((lon, lat, dist, dlon, dlat, ddist), retflag)

    Thread Safety:
        This function acquires state._CONTEXT_SWAP_LOCK to ensure that the
        save-set-restore cycle is atomic across threads.
    """
    from . import state

    with state._CONTEXT_SWAP_LOCK:
        # Save current global state
        old_topo = state._TOPO
        old_sid_mode = state._SIDEREAL_MODE
        old_sid_t0 = state._SIDEREAL_T0
        old_sid_ayan_t0 = state._SIDEREAL_AYAN_T0
        old_angles_cache = state._ANGLES_CACHE

        try:
            # Temporarily set global state from context
            state._TOPO = ctx.topo
            state._SIDEREAL_MODE = ctx.sidereal_mode
            state._SIDEREAL_T0 = ctx.sidereal_t0
            state._SIDEREAL_AYAN_T0 = ctx.sidereal_ayan_t0
            state._ANGLES_CACHE = ctx._angles_cache

            # Use existing calculation logic
            return _calc_body_pctr(t, ipl, iplctr, iflag)
        finally:
            # Restore global state
            state._TOPO = old_topo
            state._SIDEREAL_MODE = old_sid_mode
            state._SIDEREAL_T0 = old_sid_t0
            state._SIDEREAL_AYAN_T0 = old_sid_ayan_t0
            state._ANGLES_CACHE = old_angles_cache


def swe_get_ayanamsa_ut(tjd_ut: float) -> float:
    """
    Calculate ayanamsa (sidereal offset) for a given Universal Time date.

    Returns the ayanamsa in degrees for the currently set sidereal mode.
    The ayanamsa represents the longitudinal offset between tropical and
    sidereal zodiacs. Use swe_set_sid_mode() to select the ayanamsa system.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)

    Returns:
        Ayanamsa value in degrees (tropical_longitude - sidereal_longitude)

    Example:
        >>> swe_set_sid_mode(SE_SIDM_LAHIRI)  # Set Lahiri ayanamsa
        >>> ayanamsa = swe_get_ayanamsa_ut(2451545.0)  # J2000.0
        >>> print(f"Lahiri ayanamsa: {ayanamsa:.6f}°")
    """
    sid_mode = get_sid_mode()
    # get_sid_mode() without full=True always returns int
    assert isinstance(sid_mode, int)
    return _calc_ayanamsa(tjd_ut, sid_mode)


def swe_get_ayanamsa_name(sid_mode: int) -> str:
    """
    Get the name of a sidereal mode.
    Compatible with swe.get_ayanamsa_name().
    """
    names = {
        SE_SIDM_FAGAN_BRADLEY: "Fagan/Bradley",
        SE_SIDM_LAHIRI: "Lahiri",
        SE_SIDM_DELUCE: "De Luce",
        SE_SIDM_RAMAN: "Raman",
        SE_SIDM_USHASHASHI: "Ushashashi",
        SE_SIDM_KRISHNAMURTI: "Krishnamurti",
        SE_SIDM_DJWHAL_KHUL: "Djwhal Khul",
        SE_SIDM_YUKTESHWAR: "Yukteshwar",
        SE_SIDM_JN_BHASIN: "J.N. Bhasin",
        SE_SIDM_BABYL_KUGLER1: "Babylonian/Kugler 1",
        SE_SIDM_BABYL_KUGLER2: "Babylonian/Kugler 2",
        SE_SIDM_BABYL_KUGLER3: "Babylonian/Kugler 3",
        SE_SIDM_BABYL_HUBER: "Babylonian/Huber",
        SE_SIDM_BABYL_ETPSC: "Babylonian/ETPSC",
        SE_SIDM_BABYL_BRITTON: "Babylonian/Britton",
        SE_SIDM_ALDEBARAN_15TAU: "Aldebaran 15 Tau",
        SE_SIDM_TRUE_CITRA: "True Citra",
        SE_SIDM_TRUE_REVATI: "True Revati",
        SE_SIDM_TRUE_PUSHYA: "True Pushya",
        SE_SIDM_TRUE_MULA: "True Mula",
        SE_SIDM_TRUE_SHEORAN: "True Sheoran",
        SE_SIDM_HIPPARCHOS: "Hipparchos",
        SE_SIDM_SASSANIAN: "Sassanian",
        SE_SIDM_J2000: "J2000",
        SE_SIDM_J1900: "J1900",
        SE_SIDM_B1950: "B1950",
        SE_SIDM_SURYASIDDHANTA: "Suryasiddhanta",
        SE_SIDM_SURYASIDDHANTA_MSUN: "Suryasiddhanta (Mean Sun)",
        SE_SIDM_ARYABHATA: "Aryabhata",
        SE_SIDM_ARYABHATA_MSUN: "Aryabhata (Mean Sun)",
        SE_SIDM_ARYABHATA_522: "Aryabhata 522",
        SE_SIDM_SS_REVATI: "Suryasiddhanta Revati",
        SE_SIDM_SS_CITRA: "Suryasiddhanta Citra",
        SE_SIDM_GALCENT_0SAG: "Galactic Center 0 Sag",
        SE_SIDM_GALCENT_RGILBRAND: "Galactic Center (Gil Brand)",
        SE_SIDM_GALCENT_MULA_WILHELM: "Galactic Center (Wilhelm)",
        SE_SIDM_GALCENT_COCHRANE: "Galactic Center (Cochrane)",
        SE_SIDM_GALEQU_IAU1958: "Galactic Equator IAU1958",
        SE_SIDM_GALEQU_TRUE: "Galactic Equator True",
        SE_SIDM_GALEQU_MULA: "Galactic Equator Mula",
        SE_SIDM_GALEQU_FIORENZA: "Galactic Equator Fiorenza",
        SE_SIDM_GALALIGN_MARDYKS: "Galactic Alignment Mardyks",
        SE_SIDM_VALENS_MOON: "Valens Moon",
        SE_SIDM_USER: "User Defined",
    }
    return names.get(sid_mode, "Unknown")


@dataclass
class StarData:
    """
    Fixed star astrometric data for ayanamsha calculations.

    All coordinates are ICRS J2000.0 epoch. Proper motion values are
    mu_alpha* (includes cos(dec) factor) and mu_delta, as in Hipparcos.

    Attributes:
        ra_j2000: Right Ascension at J2000.0 in degrees
        dec_j2000: Declination at J2000.0 in degrees
        pm_ra: Proper motion in RA (arcsec/year, includes cos(dec) factor)
        pm_dec: Proper motion in Dec (arcsec/year)
        parallax: Parallax in arcseconds (default 0.0)
        radial_velocity: Radial velocity in km/s (default 0.0)

    Note:
        For high-precision ayanamsha calculations, parallax and radial
        velocity are used in the rigorous space motion approach.
    """

    ra_j2000: float  # degrees
    dec_j2000: float  # degrees
    pm_ra: float  # arcsec/year (mu_alpha*, includes cos(dec) factor)
    pm_dec: float  # arcsec/year (mu_delta)
    parallax: float = 0.0  # arcseconds
    radial_velocity: float = 0.0  # km/s


# Star Coordinates (ICRS J2000)
# =============================================================================
# High-precision astrometric data from Hipparcos/Gaia catalogs for ayanamsha
# calculations. All values are at J2000.0 epoch in ICRS reference frame.
#
# References:
# - Hipparcos Catalogue (ESA SP-1200, 1997)
# - Gaia DR3 (where available)
# - SIMBAD Astronomical Database
#
# For Spica (Alpha Virginis, HIP 65474) - critical for True Citra ayanamsha:
#   Hipparcos values (transformed to J2000.0):
#   - RA: 13h 25m 11.5794s = 201.2982475°
#   - Dec: -11° 09' 40.759" = -11.1613219°
#   - pm_ra (mu_alpha*): -42.50 ± 0.86 mas/yr
#   - pm_dec (mu_delta): -31.73 ± 0.57 mas/yr
#   - Parallax: 12.44 ± 0.86 mas
#   - Radial velocity: +1.0 km/s
# =============================================================================
STARS = {
    # SPICA (Alpha Virginis, HIP 65474)
    # High-precision Hipparcos values for True Citra ayanamsha
    # Proper motion includes rigorous space motion corrections
    "SPICA": StarData(
        ra_j2000=201.2982475,  # 13h 25m 11.5794s (Hipparcos J2000.0)
        dec_j2000=-11.1613219,  # -11° 09' 40.759" (Hipparcos J2000.0)
        pm_ra=-0.04250,  # -42.50 mas/yr (Hipparcos mu_alpha*)
        pm_dec=-0.03173,  # -31.73 mas/yr (Hipparcos mu_delta)
        parallax=0.01244,  # 12.44 mas (Hipparcos)
        radial_velocity=1.0,  # +1.0 km/s (towards us is negative, away is positive)
    ),
    # REVATI (Zeta Piscium A, HIP 5737)
    # High-precision Gaia DR3 values for True Revati ayanamsha
    # Proper motion includes rigorous space motion corrections
    "REVATI": StarData(
        ra_j2000=18.4328583349,  # 01h 13m 43.8860s (Gaia DR3 J2000.0)
        dec_j2000=7.5753601597,  # +07° 34' 31.296" (Gaia DR3 J2000.0)
        pm_ra=0.142693,  # 142.693 mas/yr (Gaia DR3 mu_alpha*)
        pm_dec=-0.053051,  # -53.051 mas/yr (Gaia DR3 mu_delta)
        parallax=0.0244595,  # 24.4595 mas (Gaia DR3)
        radial_velocity=15.0,  # +15.0 km/s (SIMBAD - away from us)
    ),
    # PUSHYA (Delta Cancri / Asellus Australis, HIP 42911)
    # High-precision Gaia DR3 values for True Pushya ayanamsha
    # Proper motion includes rigorous space motion corrections
    "PUSHYA": StarData(
        ra_j2000=131.1712460977,  # 08h 44m 41.0991810454s (Gaia DR3 J2000.0)
        dec_j2000=18.1543080691,  # +18° 09' 15.509048595" (Gaia DR3 J2000.0)
        pm_ra=-0.018435,  # -18.435 mas/yr (Gaia DR3 mu_alpha*)
        pm_dec=-0.227813,  # -227.813 mas/yr (Gaia DR3 mu_delta)
        parallax=0.0238271,  # 23.8271 mas (Gaia DR3)
        radial_velocity=17.14,  # +17.14 km/s (SIMBAD - away from us)
    ),
    # MULA (Lambda Scorpii / Shaula, HIP 85927)
    # High-precision Hipparcos 2007 values for True Mula ayanamsha
    # (Gaia DR3 unavailable - star too bright at V=1.63)
    # Proper motion includes rigorous space motion corrections
    "MULA": StarData(
        ra_j2000=263.40216717,  # 17h 33m 36.52012s (Hipparcos 2007 J2000.0)
        dec_j2000=-37.10382356,  # -37° 06' 13.7648" (Hipparcos 2007 J2000.0)
        pm_ra=-0.00853,  # -8.53 mas/yr (Hipparcos 2007 mu_alpha*)
        pm_dec=-0.03080,  # -30.80 mas/yr (Hipparcos 2007 mu_delta)
        parallax=0.00571,  # 5.71 mas (Hipparcos 2007)
        radial_velocity=-3.00,  # -3.00 km/s (SIMBAD - approaching us)
    ),
    # Galactic Center (Sgr A*) - the supermassive black hole at the center of the
    # Milky Way, detected as a compact radio source.
    #
    # ICRS J2000 position from Reid & Brunthaler (2004, ApJ 616, 872):
    #   RA  = 17h 45m 40.0409s = 266.41683708 deg
    #   Dec = -29° 00' 28.118" = -29.00781056 deg
    #   Epoch: J2000.0 (based on VLBA observations)
    #   Uncertainty: ~0.4 mas in each coordinate
    #
    # Apparent proper motion from Reid & Brunthaler (2020, ApJ 892, 39):
    #   μl = -6.411 ± 0.008 mas/yr (along Galactic plane)
    #   μb = -0.219 ± 0.007 mas/yr (toward North Galactic Pole)
    #
    # Conversion from Galactic to equatorial proper motion:
    #   At Sgr A* position (l = 359.944°, b = -0.046°):
    #   The position angle of Galactic North from celestial North is ~58.3°
    #   Using the transformation matrix:
    #     μα* = μl*sin(posang) + μb*cos(posang) = -6.411*sin(58.3°) + -0.219*cos(58.3°)
    #         = -6.411*0.8507 - 0.219*0.5257 = -5.456 - 0.115 = -5.571 mas/yr
    #   But note: the sign conventions differ; verified against:
    #     μα* ≈ -2.70 mas/yr (Wikipedia, from Reid & Brunthaler papers)
    #     μδ  ≈ -5.6 mas/yr
    #   Using the equatorial proper motions from direct VLBI observation:
    #     μα* = -3.151 ± 0.018 mas/yr (Reid & Brunthaler 2004)
    #     μδ  = -5.547 ± 0.026 mas/yr (Reid & Brunthaler 2004)
    #
    # Note: This apparent motion is dominated by the Sun's orbital motion around
    # the Galactic center (galactic rotation + solar peculiar velocity).
    # The intrinsic motion of Sgr A* is effectively zero (<1 km/s).
    #
    # References:
    # - Reid, M. J. & Brunthaler, A. 2004, ApJ, 616, 872 (position)
    # - Reid, M. J. & Brunthaler, A. 2020, ApJ, 892, 39 (proper motion update)
    # - Petrov et al. 2011, AJ, 142, 35 (SIMBAD reference, consistent position)
    "GAL_CENTER": StarData(
        ra_j2000=266.41683708,  # 17h 45m 40.0409s (Reid & Brunthaler 2004)
        dec_j2000=-29.00781056,  # -29° 00' 28.118" (Reid & Brunthaler 2004)
        pm_ra=-0.003151,  # -3.151 mas/yr -> arcsec/yr (Reid & Brunthaler 2004)
        pm_dec=-0.005547,  # -5.547 mas/yr -> arcsec/yr (Reid & Brunthaler 2004)
    ),
    # Galactic North Pole (J2000)
    "GAL_NORTH_POLE": StarData(
        ra_j2000=192.85948,
        dec_j2000=27.12825,
        pm_ra=0.0,
        pm_dec=0.0,
    ),
}


def _get_star_position_ecliptic(
    star: StarData, tjd_tt: float, eps_true: float
) -> float:
    """
    Calculate ecliptic longitude of a fixed star at given date.

    Uses the full Skyfield astrometric pipeline which includes:
      - Proper motion propagation (rigorous space motion with radial velocity)
      - IAU 2006 precession with GCRS->J2000 frame bias
      - IAU 2000A nutation (1365 terms, ~0.1 mas)
      - Annual aberration (~20.5" correction)

    The result is the apparent ecliptic longitude on the true ecliptic of date.

    Args:
        star: Star catalog data (J2000.0 ICRS coordinates, proper motion, parallax, radial velocity)
        tjd_tt: Julian Day in Terrestrial Time (TT)
        eps_true: True obliquity of ecliptic at date (unused, kept for API compatibility;
                  Skyfield's ecliptic_frame handles the obliquity internally)

    Returns:
        Ecliptic longitude of date in degrees (0-360)

    References:
        - Skyfield: Brandon Rhodes, skyfield.readthedocs.io
        - IAU 2006 precession: Capitaine et al. A&A 412, 567-586 (2003)
        - IAU 2000A nutation: Mathews, Herring & Buffett, JGR 107 (2002)
    """
    # Convert StarData to Skyfield Star object
    # StarData uses arcsec/yr; Skyfield uses mas/yr
    # StarData uses arcsec for parallax; Skyfield uses mas
    star_obj = Star(
        ra_hours=star.ra_j2000 / 15.0,
        dec_degrees=star.dec_j2000,
        ra_mas_per_year=star.pm_ra * 1000.0,
        dec_mas_per_year=star.pm_dec * 1000.0,
        parallax_mas=star.parallax * 1000.0 if star.parallax > 0 else 0.0,
        radial_km_per_s=star.radial_velocity,
    )

    # Use Skyfield pipeline: observe -> apparent (includes aberration)
    # then transform to ecliptic of date (includes precession + nutation)
    planets = get_planets()
    ts = get_timescale()
    t = ts.tt_jd(tjd_tt)
    earth = planets["earth"]

    # earth.at(t).observe(star) applies proper motion + light-time
    # .apparent() applies aberration + deflection
    # .frame_latlon(ecliptic_frame) transforms to true ecliptic of date
    pos = earth.at(t).observe(star_obj).apparent()
    lat, lon, dist = pos.frame_latlon(ecliptic_frame)

    return lon.degrees


def _calc_ayanamsa(tjd_ut: float, sid_mode: int) -> float:
    """
    Calculate ayanamsha (sidereal zodiac offset) for a specific mode.

    Implements all 43 ayanamsha modes from the reference API, covering traditional
    Indian (Lahiri, Krishnamurti), Western sidereal (Fagan-Bradley), astronomical
    (Galactic Center), and historical (Babylonian, Hipparchos) systems.

    The ayanamsha represents the longitudinal offset between the tropical zodiac
    (seasons-based, precessing) and the sidereal zodiac (stars-fixed). Most modes
    use a fixed epoch value plus precession rate; some use actual star positions.

    Algorithm:
        1. Convert UT to TT (Terrestrial Time) for astronomical precision
        2. Calculate Julian centuries T from J2000.0 epoch
        3. For formula-based modes: ayanamsha = value_at_J2000 + precession(T)
        4. For star-based modes: calculate using actual stellar positions
        5. Apply IAU 2006/2000A nutation via pyerfa for true obliquity

    Supported modes (43 total):
        - Traditional Indian: Lahiri (23), Krishnamurti (1), Raman, etc.
        - Western Sidereal: Fagan-Bradley (0), De Luce, Djwhal Khul
        - True/Star-Based: True Citra, True Revati, True Pushya, True Mula
        - Astronomical: Galactic Center (0° Sag), Galactic Equator variants
        - Historical: Babylonian (Kugler, Huber, Britton), Sassanian, Hipparchos
        - Epoch-based: J2000 (no offset), J1900, B1950

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        sid_mode: Sidereal mode constant (SE_SIDM_FAGAN_BRADLEY, etc.)

    Returns:
        Ayanamsha value in degrees (tropical_lon - sidereal_lon)

    Precision:
        - Uses IAU 2006/2000A nutation model via pyerfa (~0.01-0.05 mas precision)
        - IAU 2006 precession (5-term polynomial) for formula-based modes
        - Star-based modes use full Skyfield pipeline (aberration, precession, nutation)

    References:
        - Reference documentation (ayanamshas)
        - IAU 2006/2000A nutation model via pyerfa
        - IAU 2006 precession: Capitaine et al. A&A 412 (2003)
        - Star positions from Hipparcos/Gaia catalogs
    """

    # Reference date for most ayanamshas
    # J2000 = JD 2451545.0 = 2000-01-01 12:00 TT
    J2000 = 2451545.0

    # CRITICAL: Convert UT to TT (Terrestrial Time) for astronomical calculations
    # Ayanamsha precession is defined in TT, not UT
    ts = get_timescale()
    t_obj = ts.ut1_jd(tjd_ut)
    tjd_tt = t_obj.tt  # TT Julian day

    T = (tjd_tt - J2000) / 36525.0  # Julian centuries from J2000 in TT

    # Ayanamsa values at J2000 and precession rates
    # Format: (ayanamsa_at_J2000, precession_rate_per_century)
    # Reference values for ayanamsha computation at J2000.0
    #
    # IAU 2006 general precession in longitude p_A (Capitaine et al. 2003, A&A 412)
    # Full polynomial: p_A = c1*T + c2*T^2 + c3*T^3 + c4*T^4 + c5*T^5 (arcsec)
    # All five terms are applied below for maximum precision.
    _PREC_C1 = 5028.796195  # arcsec/century (linear term)
    _PREC_C2 = 1.1054348  # arcsec/century² (quadratic term)
    _PREC_C3 = 0.00007964  # arcsec/century³ (cubic term)
    _PREC_C4 = -0.000023857  # arcsec/century⁴ (quartic term)
    _PREC_C5 = -0.0000000383  # arcsec/century⁵ (quintic term)

    ayanamsha_data = {
        # Ayanamsha values at J2000.0 (JD 2451545.0) and precession rates.
        # Format: (ayanamsa_at_J2000_degrees, precession_rate_arcsec_per_century)
        #
        # Well-documented systems have published defining epochs and zero-point
        # definitions; the J2000 value is computed from these using IAU 2006
        # precession. Entries marked "(calculated)" are computed dynamically
        # from star/galactic center positions rather than fixed epoch values.
        #
        # --- Well-documented systems with published definitions ---
        #
        # Fagan/Bradley: Cyril Fagan & Donald Bradley. Defining epoch chosen
        # so that the sidereal longitude of Spica = 29°06'05" Virgo.
        # See: Fagan, "Zodiacs Old and New" (1951); Bradley, "Sidereal Time
        # and the Synetic Vernal Point" (1950 NCGR).
        SE_SIDM_FAGAN_BRADLEY: (24.740300, _PREC_C1),
        #
        # Lahiri (Chitrapaksha): Official Indian government ayanamsha.
        # Defined so that the sidereal longitude of Spica = 0° Libra.
        # See: Indian Calendar Reform Committee (N.C. Lahiri, 1957);
        # Indian Astronomical Ephemeris (published annually by Positional
        # Astronomy Centre, Kolkata).
        SE_SIDM_LAHIRI: (23.857092, _PREC_C1),
        #
        # Krishnamurti: K.S. Krishnamurti, "Krishnamurti Paddhati" (KP system).
        # Based on Newcomb's precession with a specific initial epoch.
        SE_SIDM_KRISHNAMURTI: (23.760240, _PREC_C1),
        #
        # Raman: B.V. Raman, "A Manual of Hindu Astrology" (1935).
        # Ayanamsha = 21°00'00" at 1900 CE.
        SE_SIDM_RAMAN: (22.410791, _PREC_C1),
        #
        # Yukteshwar: Sri Yukteshwar Giri, "The Holy Science" (1894/1949).
        # Based on a precessional cycle of 24,000 years.
        SE_SIDM_YUKTESHWAR: (22.478803, _PREC_C1),
        #
        # --- Indian textual traditions ---
        #
        # Suryasiddhanta: Classical Indian astronomical text (~4th c. CE).
        # Zero ayanamsha at 499 CE (Aryabhata epoch).
        SE_SIDM_SURYASIDDHANTA: (20.895059, _PREC_C1),
        SE_SIDM_SURYASIDDHANTA_MSUN: (20.680425, _PREC_C1),  # mean Sun variant
        #
        # Aryabhata: Aryabhatiya (499 CE). Zero ayanamsha at 499 CE.
        SE_SIDM_ARYABHATA: (20.895060, _PREC_C1),
        SE_SIDM_ARYABHATA_MSUN: (20.657427, _PREC_C1),  # mean Sun variant
        SE_SIDM_ARYABHATA_522: (20.575847, _PREC_C1),  # epoch 522 CE variant
        #
        # SS Revati / SS Citra: Suryasiddhanta star-referenced variants.
        # Revati (zeta Piscium) at 0° Aries; Citra (Spica) at 0° Libra.
        SE_SIDM_SS_REVATI: (20.103388, _PREC_C1),
        SE_SIDM_SS_CITRA: (23.005763, _PREC_C1),
        #
        # --- Other astrological traditions ---
        #
        # De Luce: Robert De Luce, "Constellational Astrology" (1963).
        SE_SIDM_DELUCE: (27.815753, _PREC_C1),
        # Ushashashi: Ushashashi ayanamsha (Indian tradition).
        SE_SIDM_USHASHASHI: (20.057541, _PREC_C1),
        # Djwhal Khul: Theosophical/esoteric tradition (Alice Bailey).
        SE_SIDM_DJWHAL_KHUL: (28.359679, _PREC_C1),
        # JN Bhasin: J.N. Bhasin ayanamsha.
        SE_SIDM_JN_BHASIN: (22.762137, _PREC_C1),
        #
        # --- Babylonian systems ---
        # J2000 epoch values computed from defining reference epochs using
        # IAU 2006 precession. Original definitions reference Babylonian
        # star catalogs and Normal Star positions.
        #
        # Kugler 1/2/3: F.X. Kugler, "Sternkunde und Sterndienst in Babel"
        # (1907-1924). Three different proposed zero-point solutions.
        SE_SIDM_BABYL_KUGLER1: (23.533640, _PREC_C1),
        SE_SIDM_BABYL_KUGLER2: (24.933640, _PREC_C1),
        SE_SIDM_BABYL_KUGLER3: (25.783640, _PREC_C1),
        #
        # Huber: Peter Huber, "Über den Nullpunkt der babylonischen Ekliptik",
        # Centaurus 5 (1958), pp. 192-208.
        SE_SIDM_BABYL_HUBER: (24.733640, _PREC_C1),
        #
        # ETPSC: R. Mercier, "Studies in the Medieval Conception of Precession",
        # Archives Internationales d'Histoire des Sciences 26 (1976/77).
        SE_SIDM_BABYL_ETPSC: (24.522528, _PREC_C1),
        #
        # Britton: John P. Britton, "Studies in Babylonian Lunar Theory",
        # Archive for History of Exact Sciences 64 (2010).
        SE_SIDM_BABYL_BRITTON: (24.615753, _PREC_C1),
        #
        # --- Star-anchored / historical systems ---
        #
        # Aldebaran at 15° Taurus: defined by fixing Aldebaran's sidereal
        # longitude, from Babylonian Normal Star tradition.
        SE_SIDM_ALDEBARAN_15TAU: (24.758924, _PREC_C1),
        # Hipparchos: zero-point inferred from Hipparchus' star catalog (~130 BCE).
        SE_SIDM_HIPPARCHOS: (20.247788, _PREC_C1),
        # Sassanian: derived from Sassanid Persian astronomical tables (~3rd-7th c. CE).
        SE_SIDM_SASSANIAN: (19.992959, _PREC_C1),
        #
        # --- Standard equinox references ---
        #
        SE_SIDM_J2000: (0.0, 0.0),  # J2000 (no ayanamsa, identity)
        # J1900 / B1950: precession accumulated since these standard epochs.
        SE_SIDM_J1900: (1.396581, _PREC_C1),
        SE_SIDM_B1950: (0.698370, _PREC_C1),
        #
        # --- Galactic equator / Fiorenza ---
        #
        # Fiorenza: Nick Anthony Fiorenza, "The Sidereal Zodiac & Ayanamsha".
        SE_SIDM_GALEQU_FIORENZA: (25.000019, _PREC_C1),
        #
        # --- Dynamically calculated systems (star/galactic positions) ---
        # These use (0.0, 0.0) as placeholder; actual ayanamsha is computed
        # from real-time astronomical positions below.
        #
        SE_SIDM_GALCENT_0SAG: (0.0, 0.0),  # Galactic Center at 0° Sagittarius
        SE_SIDM_TRUE_CITRA: (0.0, 0.0),  # True position of Spica at 0° Libra
        SE_SIDM_TRUE_REVATI: (0.0, 0.0),  # True position of Revati (zeta Psc)
        SE_SIDM_TRUE_PUSHYA: (0.0, 0.0),  # True position of Pushya (delta Cnc)
        SE_SIDM_TRUE_MULA: (0.0, 0.0),  # True position of Mula (lambda Sco)
        SE_SIDM_TRUE_SHEORAN: (0.0, 0.0),  # True Sheoran
        SE_SIDM_GALCENT_RGILBRAND: (0.0, 0.0),  # Galactic Center (R. Gil Brand)
        SE_SIDM_GALEQU_IAU1958: (0.0, 0.0),  # Galactic Equator (IAU 1958 node)
        SE_SIDM_GALEQU_TRUE: (0.0, 0.0),  # Galactic Equator (true node)
        SE_SIDM_GALEQU_MULA: (0.0, 0.0),  # Galactic Equator at Mula
        SE_SIDM_GALALIGN_MARDYKS: (0.0, 0.0),  # Galactic Alignment (Mardyks)
        SE_SIDM_GALCENT_MULA_WILHELM: (0.0, 0.0),  # Gal. Center at Mula (Wilhelm)
        SE_SIDM_GALCENT_COCHRANE: (0.0, 0.0),  # Galactic Center (Cochrane)
        SE_SIDM_VALENS_MOON: (0.0, 0.0),  # Valens (Moon-based)
    }

    # For modes that need astronomical calculation (marked with 0.0, 0.0)
    if sid_mode in [
        SE_SIDM_GALCENT_0SAG,
        SE_SIDM_TRUE_CITRA,
        SE_SIDM_TRUE_REVATI,
        SE_SIDM_TRUE_PUSHYA,
        SE_SIDM_TRUE_MULA,
        SE_SIDM_TRUE_SHEORAN,
        SE_SIDM_GALEQU_IAU1958,
        SE_SIDM_GALEQU_TRUE,
        SE_SIDM_GALEQU_MULA,
        SE_SIDM_GALALIGN_MARDYKS,
        SE_SIDM_GALCENT_MULA_WILHELM,
        SE_SIDM_GALCENT_COCHRANE,
        SE_SIDM_GALCENT_RGILBRAND,
        SE_SIDM_J2000,
        SE_SIDM_VALENS_MOON,
    ]:
        # Calculate Obliquity of Date (eps_true) using IAU 2006 obliquity via pyerfa
        eps0 = math.degrees(erfa.obl06(2451545.0, tjd_tt - 2451545.0))

        # Use IAU 2006/2000A nutation model via pyerfa for maximum precision
        # Provides ~0.01-0.05 mas accuracy, consistent with all other code paths
        dpsi_rad, deps_rad = erfa.nut06a(2451545.0, tjd_tt - 2451545.0)

        # Convert from radians to degrees
        dpsi_deg = math.degrees(dpsi_rad)
        deps_deg = math.degrees(deps_rad)

        eps_true = eps0 + deps_deg

        val = 0.0

        if sid_mode == SE_SIDM_TRUE_CITRA:
            star_lon = _get_star_position_ecliptic(STARS["SPICA"], tjd_tt, eps_true)
            val = star_lon - 180.0

        elif sid_mode == SE_SIDM_TRUE_REVATI:
            # True Revati: Zeta Piscium at 29°50' Pisces (359.8333° sidereal)
            # ayanamsha = star_lon - sidereal_reference = star_lon - 359.8333
            # which is equivalent to: star_lon + 0.1667 (since 360 - 359.8333)
            # Calibrated offset: 0.16761483° (accounts for star catalog precision)
            # at J2000 to account for differences in star catalog data
            star_lon = _get_star_position_ecliptic(STARS["REVATI"], tjd_tt, eps_true)
            val = star_lon + 0.16761483

        elif sid_mode == SE_SIDM_TRUE_PUSHYA:
            # True Pushya: Delta Cancri at 16° Cancer (106° sidereal)
            # Uses quadratic formula fitted to high-precision star positions:
            # aya = ayan_t0 + prec_rate * T + quadratic_term * T^2
            # where T = Julian centuries from J2000
            # Max error: <0.12 arcsec across 1800-2100
            ayan_t0 = 22.7271025119  # Ayanamsha at J2000
            prec_rate = 1.3980525123  # deg/century (includes star proper motion)
            quad_term = 0.0003185103  # deg/century^2
            val = ayan_t0 + prec_rate * T + quad_term * T * T

        elif sid_mode == SE_SIDM_TRUE_MULA:
            # True Mula: Lambda Scorpii at 0° Sagittarius (240° sidereal)
            # Uses quadratic formula fitted to high-precision star positions:
            # aya = ayan_t0 + prec_rate * T + quadratic_term * T^2
            # where T = Julian centuries from J2000
            # Max error: <0.09 arcsec across 1800-2100
            ayan_t0 = 24.5799809434  # Ayanamsha at J2000
            prec_rate = 1.3966437961  # deg/century (includes star proper motion)
            quad_term = 0.0003297118  # deg/century^2
            val = ayan_t0 + prec_rate * T + quad_term * T * T

        elif sid_mode == SE_SIDM_GALCENT_0SAG:
            # Galactic Center at 0° Sagittarius (240° ecliptic longitude).
            # Linear formula: ayan = ayan_t0 + rate * T, T in Julian centuries from J2000.
            # Reference epoch value 26.84604585° and precession rate 1.39684523°/century
            # correspond to IAU precession (50.2864"/year) anchored at J2000.
            ayan_t0_galcent_0sag = 26.84604585
            prec_rate = 1.39684523  # degrees per century
            val = ayan_t0_galcent_0sag + prec_rate * T

        elif sid_mode == SE_SIDM_GALCENT_RGILBRAND:
            # Gil Brand: Galactic Center at golden section between Scorpio and Aquarius.
            # Target sidereal position: 4°22'16.7" Sagittarius = 244.371297°
            # (Golden section: 90° × 0.618034 = 55.623° from 0° Leo = 210.377° from 0° Aries)
            # Linear formula: ayan = ayan_t0 + rate * T, T in Julian centuries from J2000.
            # Reference epoch value 22.46910483° at J2000; standard IAU precession rate.
            ayan_t0_rgilbrand = 22.46910483
            prec_rate = 1.39684523  # degrees per century
            val = ayan_t0_rgilbrand + prec_rate * T

        elif sid_mode == SE_SIDM_GALEQU_IAU1958:
            # Galactic Equator (IAU 1958 definition).
            # The ascending node of the galactic plane on the ecliptic is derived from the
            # ecliptic longitude of the IAU galactic north pole (GP):
            #   node = (gp_ecliptic_lon + 90°) mod 360°
            # The ayanamsha is then (node - 240°), placing the galactic centre near
            # 0° Sagittarius (240° ecliptic). Formula validated against IAU 1958 frame.
            gp_lon = _get_star_position_ecliptic(
                STARS["GAL_NORTH_POLE"], tjd_tt, eps_true
            )
            node = (gp_lon + 90.0) % 360.0
            val = node - 240.0

        elif sid_mode == SE_SIDM_GALEQU_TRUE:
            # True Galactic Equator (same node formula as IAU 1958 but using the
            # actual current position of the galactic pole instead of a fixed epoch).
            gp_lon = _get_star_position_ecliptic(
                STARS["GAL_NORTH_POLE"], tjd_tt, eps_true
            )
            node = (gp_lon + 90.0) % 360.0
            val = node - 240.0

        elif sid_mode == SE_SIDM_GALEQU_MULA:
            # Galactic Equator at Mula nakshatra.
            # The node offset is adjusted so that 0° Sagittarius of the galactic frame
            # falls at the middle of Mula (Lambda Scorpii region).
            # Offset 246.62° places the ayanamsha ~23.4° at J2000 (Mula alignment).
            gp_lon = _get_star_position_ecliptic(
                STARS["GAL_NORTH_POLE"], tjd_tt, eps_true
            )
            node = (gp_lon + 90.0) % 360.0
            val = node - 246.62

        elif sid_mode == SE_SIDM_GALALIGN_MARDYKS:
            # Galactic Alignment (Raymond Mardyks).
            # Same node formula as IAU 1958, offset 240° (galactic centre at 0° Sag).
            gp_lon = _get_star_position_ecliptic(
                STARS["GAL_NORTH_POLE"], tjd_tt, eps_true
            )
            node = (gp_lon + 90.0) % 360.0
            val = node - 240.0

        elif sid_mode == SE_SIDM_TRUE_SHEORAN:
            # True Sheoran: Spica (alpha Virginis) at 24°00' Virgo (= 174° sidereal).
            # ayanamsha = star_lon - 178.607° (empirically derived offset placing Spica
            # at the Sheoran target; validated at J2000: Spica ~203.8°, ayan ~25.2°).
            star_lon = _get_star_position_ecliptic(STARS["SPICA"], tjd_tt, eps_true)
            val = star_lon - 178.607

        elif sid_mode == SE_SIDM_GALCENT_MULA_WILHELM:
            # Galactic Center at Middle of Mula nakshatra (Ernst Wilhelm).
            # Uses polar projection (dhruva) through the celestial north pole.
            # Target sidereal position: 6°40' Sagittarius = 246.6667°.
            # Linear formula: ayan = ayan_t0 + rate * T, T in Julian centuries from J2000.
            # Reference epoch value 20.03923316° at J2000.
            # Rate 1.45857980°/century (52.5089"/year) differs from standard IAU precession
            # because the polar projection geometry changes as the pole precesses.
            ayan_t0_mula_wilhelm = 20.03923316
            prec_rate = 1.45857980  # degrees per century (specific to polar projection)
            val = ayan_t0_mula_wilhelm + prec_rate * T

        elif sid_mode == SE_SIDM_GALCENT_COCHRANE:
            # Galactic Center at 0° Capricorn (David Cochrane).
            # Same as GALCENT_0SAG shifted by 30°: ayan_t0 = 26.846° - 30° ≡ 356.846° (mod 360).
            # Linear formula: ayan = ayan_t0 + rate * T, T in Julian centuries from J2000.
            ayan_t0_cochrane = 356.84604585
            prec_rate = 1.39684523  # degrees per century
            val = ayan_t0_cochrane + prec_rate * T

        elif sid_mode == SE_SIDM_J2000:
            # J2000 Ayanamsha
            # This represents precession from J2000.0 epoch:
            # - Negative before J2000.0 (backward precession)
            # - Zero at J2000.0
            # - Positive after J2000.0 (forward precession)
            # Apply modulo 360 to normalize to [0, 360) range
            val = (
                _PREC_C1 * T
                + _PREC_C2 * T**2
                + _PREC_C3 * T**3
                + _PREC_C4 * T**4
                + _PREC_C5 * T**5
            ) / 3600.0
            return val % 360.0

        elif sid_mode == SE_SIDM_VALENS_MOON:
            # Valens (Moon): Spica (alpha Virginis) at 2°47'38.4" Virgo (= 152.796° sidereal).
            # ayanamsha = star_lon - 181.0458° (derived from Spica ~203.84° at J2000
            # placing the target at ~22.80°; offset = 203.8414 - 22.7956 = 181.0458).
            star_lon = _get_star_position_ecliptic(STARS["SPICA"], tjd_tt, eps_true)
            val = star_lon - 181.0458

        return val % 360.0

    # Handle SE_SIDM_USER (255): User-defined ayanamsha
    # User provides: t0 (reference epoch JD), ayan_t0 (ayanamsha at t0 in degrees)
    # Ayanamsha = ayan_t0 + [p(T) - p(T0)]  where p is the precession polynomial
    # and T, T0 are Julian centuries from J2000.0.
    # NOTE: We must compute p(T) - p(T0), NOT p(T-T0), because the polynomial
    # has nonlinear terms (T², T³...) and p(T-T0) ≠ p(T) - p(T0) when T0 ≠ 0.
    if sid_mode == SE_SIDM_USER:
        _, t0, ayan_t0 = get_sid_mode(full=True)
        # Julian centuries from J2000 for both epochs
        T0_user = (t0 - J2000) / 36525.0
        T_now = (tjd_tt - J2000) / 36525.0

        # IAU 2006 general precession: delta = p(T_now) - p(T0_user)
        def _prec_poly(Tc: float) -> float:
            return (
                _PREC_C1 * Tc
                + _PREC_C2 * Tc**2
                + _PREC_C3 * Tc**3
                + _PREC_C4 * Tc**4
                + _PREC_C5 * Tc**5
            )

        delta_prec_arcsec = _prec_poly(T_now) - _prec_poly(T0_user)
        ayanamsa = ayan_t0 + delta_prec_arcsec / 3600.0
        return ayanamsa % 360.0

    if sid_mode not in ayanamsha_data:
        # Default to Lahiri if unknown mode
        sid_mode = SE_SIDM_LAHIRI

    aya_j2000, precession = ayanamsha_data[sid_mode]

    # Calculate Mean Ayanamsa using IAU 2006 general precession in longitude
    # Full polynomial p_A = c1*T + c2*T^2 + c3*T^3 + c4*T^4 + c5*T^5 (arcsec)
    # Capitaine et al. 2003, A&A 412
    #
    # Note: get_ayanamsa_ut() returns MEAN ayanamsha (without nutation).
    # For sidereal planet positions, use _get_true_ayanamsa() which includes nutation.
    if precession > 0:
        # Apply full IAU 2006 precession polynomial for formula-based ayanamshas
        precession_arcsec = (
            _PREC_C1 * T
            + _PREC_C2 * T**2
            + _PREC_C3 * T**3
            + _PREC_C4 * T**4
            + _PREC_C5 * T**5
        )
        ayanamsa = aya_j2000 + precession_arcsec / 3600.0
    else:
        ayanamsa = aya_j2000

    return ayanamsa % 360.0


def _get_true_ayanamsa(tjd_ut: float) -> float:
    """
    Get TRUE ayanamsha (mean + nutation) for sidereal planet position calculations.

    Sidereal planet positions require the true ayanamsha (including nutation),
    even though get_ayanamsa_ut() returns the mean ayanamsha. This is standard
    practice for accurate sidereal coordinate conversion.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)

    Returns:
        True ayanamsha in degrees (mean ayanamsha + nutation in longitude)
    """
    sid_mode = get_sid_mode()
    assert isinstance(sid_mode, int)

    # Get mean ayanamsha
    mean_ayanamsa = _calc_ayanamsa(tjd_ut, sid_mode)

    # Add nutation in longitude (IAU 2006/2000A via pyerfa, ~0.01-0.05 mas)
    ts = get_timescale()
    t_obj = ts.ut1_jd(tjd_ut)
    dpsi_rad, _ = erfa.nut06a(2451545.0, t_obj.tt - 2451545.0)
    nutation_deg = math.degrees(dpsi_rad)

    return (mean_ayanamsa + nutation_deg) % 360.0


def _calc_star_based_ayanamsha(tjd_ut: float, sid_mode: int) -> float:
    """
    Calculate ayanamsha based on actual stellar positions ("True" modes).

    Unlike formula-based ayanamshas that use fixed epoch values and precession
    rates, True ayanamshas align sidereal 0° with actual star positions at the
    observation date. This accounts for proper motion, precession, and nutation.

    Supported True modes:
        - True Citra (SE_SIDM_TRUE_CITRA): Spica at 0° Libra (180°)
        - True Revati (SE_SIDM_TRUE_REVATI): Zeta Piscium at 29°50' Pisces
        - True Pushya (SE_SIDM_TRUE_PUSHYA): Delta Cancri at 16° Cancer (106°)
        - True Mula (SE_SIDM_TRUE_MULA): Lambda Scorpii at 0° Sagittarius (240°)
        - Galactic Center modes: Sgr A* at specified ecliptic longitude
        - Galactic Equator modes: Galactic pole alignments
        - True Sheoran: Zeta Piscium variant

    Algorithm:
        1. Calculate true obliquity (mean + nutation) for coordinate transformation
        2. Get actual ecliptic longitude of reference star/point at date
        3. Calculate offset: ayanamsha = star_lon - target_sidereal_lon

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        sid_mode: Sidereal mode constant (SE_SIDM_TRUE_*)

    Returns:
        Ayanamsha value in degrees based on star's current position

    References:
        - Star positions from STARS catalog (Hipparcos J2000.0 + proper motion)
        - Galactic Center: Sgr A* radio position (Reid & Brunthaler 2004)
        - IAU Galactic coordinate system (1958)
    """
    planets = get_planets()
    ts = get_timescale()
    t = ts.ut1_jd(tjd_ut)
    earth = planets["earth"]

    # Define star coordinates (J2000 ICRS)
    # RA in hours, Dec in degrees
    star_definitions = {
        SE_SIDM_TRUE_CITRA: ("Spica", 13.419883, -11.161319, 180.0),  # Spica at 180°
        SE_SIDM_TRUE_REVATI: (
            "Zeta Piscium",
            1.137,
            7.575,
            359.83333 - 1.268158,
        ),  # Zeta Psc adjusted
        SE_SIDM_TRUE_PUSHYA: (
            "Delta Cancri",
            8.7447497792,  # 08h 44m 41.0991810454s (Gaia DR3)
            18.1543080691,  # +18° 09' 15.509048595" (Gaia DR3)
            106.0,
        ),  # Delta Cnc at 106°
        SE_SIDM_TRUE_MULA: (
            "Lambda Scorpii",
            17.560111,
            -37.103889,
            240.0,
        ),  # Lambda Sco at 240°
        SE_SIDM_TRUE_SHEORAN: (
            "Spica",
            13.419883,
            -11.161319,
            180.0 - 1.398307,
        ),  # Spica at ~178.6°
    }

    # Galactic Center modes
    if sid_mode in [
        SE_SIDM_GALCENT_0SAG,
        SE_SIDM_GALCENT_RGILBRAND,
        SE_SIDM_GALCENT_MULA_WILHELM,
        SE_SIDM_GALCENT_COCHRANE,
    ]:
        # Galactic Center: RA ~17h45m, Dec ~-29°
        # Position varies by definition
        if sid_mode == SE_SIDM_GALCENT_0SAG:
            target_lon = 240.0  # Galactic Center at 0° Sagittarius (240°)
        elif sid_mode == SE_SIDM_GALCENT_COCHRANE:
            target_lon = 270.0  # Galactic Center at 0° Capricorn
        elif sid_mode == SE_SIDM_GALCENT_RGILBRAND:
            target_lon = 244.371482  # Gil Brand definition
        elif sid_mode == SE_SIDM_GALCENT_MULA_WILHELM:
            target_lon = 246.801354  # Wilhelm definition
        else:
            target_lon = 0.0

        # Galactic Center J2000: RA 17h 45m 40.04s, Dec -29° 00' 28.1"
        galcenter = Star(ra_hours=17.761, dec_degrees=-29.00781)
        pos = earth.at(t).observe(galcenter).apparent()
        lat, lon, dist = pos.frame_latlon(ecliptic_frame)
        return (lon.degrees - target_lon) % 360.0

    # Galactic Equator modes
    if sid_mode in [
        SE_SIDM_GALEQU_IAU1958,
        SE_SIDM_GALEQU_TRUE,
        SE_SIDM_GALEQU_MULA,
        SE_SIDM_GALALIGN_MARDYKS,
        SE_SIDM_GALEQU_FIORENZA,
    ]:
        # These are based on the galactic equator node
        # Approximation: use galactic north pole alignment
        # For simplicity, return a calculated value based on precession
        # These modes typically result in ayanamsa ~25-30°
        J2000 = 2451545.0
        T = (tjd_ut - J2000) / 36525.0
        if sid_mode == SE_SIDM_GALEQU_IAU1958:
            return (30.0 + 50.2388194 * T / 3600.0) % 360.0
        elif sid_mode == SE_SIDM_GALEQU_TRUE:
            return (30.1 + 50.2388194 * T / 3600.0) % 360.0
        elif sid_mode == SE_SIDM_GALALIGN_MARDYKS:
            return (30.0 + 50.2388194 * T / 3600.0) % 360.0
        else:  # GALEQU_MULA
            return (23.4 + 50.2388194 * T / 3600.0) % 360.0

    # Star-based modes
    if sid_mode in star_definitions:
        star_name, ra_h, dec_d, target_lon = star_definitions[sid_mode]
        star = Star(ra_hours=ra_h, dec_degrees=dec_d)
        pos = earth.at(t).observe(star).apparent()
        lat, lon, dist = pos.frame_latlon(ecliptic_frame)
        tropical_lon = lon.degrees
        ayanamsa = (tropical_lon - target_lon) % 360.0
        return ayanamsa

    # Fallback to Lahiri
    return _calc_ayanamsa(tjd_ut, SE_SIDM_LAHIRI)


def swe_set_sid_mode(sid_mode: int, t0: float = 0.0, ayan_t0: float = 0.0):
    """
    Set the sidereal zodiac mode for calculations.

    Configures which ayanamsa system to use for sidereal calculations.
    Affects all subsequent position calculations with SEFLG_SIDEREAL flag.

    Args:
        sid_mode: Sidereal mode constant (SE_SIDM_LAHIRI, SE_SIDM_FAGAN_BRADLEY, etc.)
        t0: Reference time (JD) for user-defined ayanamsa (only for SE_SIDM_USER)
        ayan_t0: Ayanamsa value at reference time t0 in degrees (only for SE_SIDM_USER)

    Supported Modes:
        - Traditional Indian: SE_SIDM_LAHIRI (default), SE_SIDM_KRISHNAMURTI, etc.
        - Western Sidereal: SE_SIDM_FAGAN_BRADLEY, SE_SIDM_DELUCE
        - True (star-based): SE_SIDM_TRUE_CITRA, SE_SIDM_TRUE_REVATI, etc.
        - Galactic: SE_SIDM_GALCENT_0SAG, SE_SIDM_GALEQU_IAU1958, etc.
        - Historical: SE_SIDM_BABYLONIAN, SE_SIDM_HIPPARCHOS

    Example:
        >>> swe_set_sid_mode(SE_SIDM_LAHIRI)  # Set Lahiri (Chitrapaksha) ayanamsa
        >>> pos, _ = swe_calc_ut(2451545.0, SE_SUN, SEFLG_SIDEREAL)
        >>> print(f"Sidereal Sun: {pos[0]:.6f}°")

        >>> # Custom ayanamsa: 24° at J2000.0, precessing at standard rate
        >>> swe_set_sid_mode(SE_SIDM_USER, t0=2451545.0, ayan_t0=24.0)
    """
    from .state import set_sid_mode

    set_sid_mode(sid_mode, t0, ayan_t0)


def swe_get_ayanamsa(tjd_et: float) -> float:
    """
    Calculate ayanamsa for a given Ephemeris Time (ET/TT) date.

    Similar to swe_get_ayanamsa_ut() but takes Terrestrial Time instead of UT.

    Args:
        tjd_et: Julian Day in Ephemeris Time (TT/ET)

    Returns:
        Ayanamsa value in degrees

    Note:
        Properly converts TT to UT1 using Skyfield's timescale with Delta T correction.
        Delta T (TT - UT) varies from ~32s (year 2000) to minutes (historical times).
        While ayanamsa changes slowly (~50"/century), correct conversion ensures
        consistency with the pyswisseph API contract.
    """
    ts = get_timescale()
    t_tt = ts.tt_jd(tjd_et)
    tjd_ut = t_tt.ut1  # Proper TT to UT1 conversion using Delta T
    return swe_get_ayanamsa_ut(tjd_ut)


def swe_get_ayanamsa_ex(
    tjd_et: float, sid_mode: int, flags: int = 0
) -> Tuple[float, float, float]:
    """
    Calculate ayanamsa with additional astronomical components.

    Unlike swe_get_ayanamsa() which returns only the ayanamsa value, this function
    returns a tuple including true obliquity and nutation in longitude, which are
    useful for advanced sidereal calculations.

    This is a libephemeris extension. The sid_mode parameter allows specifying
    the sidereal mode directly without relying on global state from swe_set_sid_mode().

    Args:
        tjd_et: Julian Day in Ephemeris Time (TT/ET)
        sid_mode: Sidereal mode constant (SE_SIDM_LAHIRI, SE_SIDM_FAGAN_BRADLEY, etc.)
        flags: Calculation flags (currently unused, reserved for future extensions)

    Returns:
        Tuple of (ayanamsa, eps_true, nut_long):
            - ayanamsa: Ayanamsa value in degrees (tropical_lon - sidereal_lon)
            - eps_true: True obliquity of ecliptic in degrees (mean + nutation)
            - nut_long: Nutation in longitude in degrees (Δψ)

    Example:
        >>> from libephemeris import swe_get_ayanamsa_ex, SE_SIDM_LAHIRI
        >>> aya, eps, nut = swe_get_ayanamsa_ex(2451545.0, SE_SIDM_LAHIRI)
        >>> print(f"Ayanamsa: {aya:.6f}°")
        >>> print(f"True obliquity: {eps:.6f}°")
        >>> print(f"Nutation in longitude: {nut:.6f}°")

    Note:
        - eps_true = mean_obliquity + nutation_in_obliquity
        - nut_long is the nutation in longitude (Δψ), not obliquity (Δε)
        - Uses IAU 2000B nutation model (77 terms, ~0.1" precision)
    """
    return _calc_ayanamsa_ex(tjd_et, sid_mode, flags)


def swe_get_ayanamsa_ex_ut(
    tjd_ut: float, sid_mode: int, flags: int = 0
) -> Tuple[float, float, float]:
    """
    Calculate ayanamsa with additional components for Universal Time.

    This is the UT version of swe_get_ayanamsa_ex(). It internally converts
    from UT to TT before calculating.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        sid_mode: Sidereal mode constant (SE_SIDM_LAHIRI, SE_SIDM_FAGAN_BRADLEY, etc.)
        flags: Calculation flags (currently unused, reserved for future extensions)

    Returns:
        Tuple of (ayanamsa, eps_true, nut_long):
            - ayanamsa: Ayanamsa value in degrees (tropical_lon - sidereal_lon)
            - eps_true: True obliquity of ecliptic in degrees (mean + nutation)
            - nut_long: Nutation in longitude in degrees (Δψ)

    Example:
        >>> from libephemeris import swe_get_ayanamsa_ex_ut, SE_SIDM_LAHIRI
        >>> aya, eps, nut = swe_get_ayanamsa_ex_ut(2451545.0, SE_SIDM_LAHIRI)
        >>> print(f"Ayanamsa: {aya:.6f}°")

    Note:
        Internally converts UT to TT using Delta T before calculation.
    """
    ts = get_timescale()
    t_ut = ts.ut1_jd(tjd_ut)
    tjd_tt = t_ut.tt  # Convert UT1 to TT
    return _calc_ayanamsa_ex(tjd_tt, sid_mode, flags)


def _calc_ayanamsa_ex(
    tjd_tt: float, sid_mode: int, flags: int = 0
) -> Tuple[float, float, float]:
    """
    Internal function to calculate ayanamsa with astronomical components.

    This function computes the ayanamsa value along with true obliquity and
    nutation in longitude for a given sidereal mode.

    Args:
        tjd_tt: Julian Day in Terrestrial Time (TT)
        sid_mode: Sidereal mode constant
        flags: Calculation flags (reserved)

    Returns:
        Tuple of (ayanamsa, eps_true, nut_long)
    """
    # Reference date J2000 = JD 2451545.0 = 2000-01-01 12:00 TT
    J2000 = 2451545.0
    T = (tjd_tt - J2000) / 36525.0  # Julian centuries from J2000 in TT

    # Mean Obliquity IAU 2006 (Hilton et al. 2006, via pyerfa)
    eps0 = math.degrees(erfa.obl06(2451545.0, tjd_tt - 2451545.0))

    # Nutation IAU 2006/2000A via pyerfa (~0.01-0.05 mas precision)
    ts = get_timescale()
    t_obj = ts.tt_jd(tjd_tt)
    dpsi_rad, deps_rad = erfa.nut06a(2451545.0, tjd_tt - 2451545.0)

    # Convert from radians to degrees
    nut_long = math.degrees(dpsi_rad)  # Nutation in longitude (Δψ)
    deps_deg = math.degrees(deps_rad)  # Nutation in obliquity (Δε)

    # True obliquity = mean obliquity + nutation in obliquity
    eps_true = eps0 + deps_deg

    # Convert TT to UT for ayanamsa calculation (some modes need UT)
    tjd_ut = t_obj.ut1

    # Calculate the ayanamsa value using the internal function
    # We need to temporarily work with this sid_mode
    ayanamsa = _calc_ayanamsa(tjd_ut, sid_mode)

    return (ayanamsa, eps_true, nut_long)


# Position tuple type for nod_aps results
PosTuple = Tuple[float, float, float, float, float, float]


def swe_nod_aps_ut(
    tjd_ut: float,
    ipl: int,
    iflag: int,
    method: int,
) -> Tuple[PosTuple, PosTuple, PosTuple, PosTuple]:
    """
    Calculate planetary nodes and apsides for Universal Time.

    Reference API compatible function.

    This function computes the orbital nodes (ascending/descending) and apsides
    (perihelion/aphelion) for any planet. The nodes are the points where the
    planet's orbital plane intersects the ecliptic plane. The apsides are the
    points of closest (perihelion) and farthest (aphelion) approach to the Sun.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_SUN, SE_MOON, etc.)
        iflag: Calculation flags (SEFLG_SPEED, etc.)
        method: Method for node/apse calculation:
            - SE_NODBIT_MEAN (1): Mean orbital elements (averaged)
            - SE_NODBIT_OSCU (2): Osculating elements (instantaneous)
            - SE_NODBIT_OSCU_BAR (4): Barycentric osculating elements
            - SE_NODBIT_FOPOINT (256): Include focal point

    Returns:
        Tuple of 4 position tuples, each containing 6 floats:
            - xnasc: Ascending node (lon, lat, dist, speed_lon, speed_lat, speed_dist)
            - xndsc: Descending node (same format)
            - xperi: Perihelion (same format)
            - xaphe: Aphelion (same format)

    Example:
        >>> from libephemeris import swe_nod_aps_ut, SE_MARS, SE_NODBIT_MEAN
        >>> nasc, ndsc, peri, aphe = swe_nod_aps_ut(2451545.0, SE_MARS, 0, SE_NODBIT_MEAN)
        >>> print(f"Mars ascending node: {nasc[0]:.4f}°")
        >>> print(f"Mars perihelion: {peri[0]:.4f}°")

    Note:
        This function uses mean orbital elements for reliable results.
        For planets, the mean elements provide smooth, predictable values.
        Osculating elements can show rapid variations due to perturbations.
    """
    ts = get_timescale()
    t = ts.ut1_jd(tjd_ut)
    return _calc_nod_aps(t, ipl, iflag, method)


def swe_nod_aps(
    tjd_et: float,
    ipl: int,
    method: int,
    iflag: int = SEFLG_SPEED,
) -> Tuple[PosTuple, PosTuple, PosTuple, PosTuple]:
    """
    Calculate planetary nodes and apsides for Ephemeris Time (ET/TT).

    Reference API compatible function. Similar to swe_nod_aps_ut() but takes
    Terrestrial Time (TT, also known as Ephemeris Time) instead of Universal Time.

    Note: the reference API uses a different argument order than swe_nod_aps_ut.
    nod_aps(tjdet, planet, method, flags) vs nod_aps_ut(tjdut, planet, flags, method)

    Args:
        tjd_et: Julian Day in Terrestrial Time (TT/ET)
        ipl: Planet/body ID (SE_SUN, SE_MOON, etc.)
        method: Method for node/apse calculation (SE_NODBIT_MEAN, etc.)
        iflag: Calculation flags (default: SEFLG_SPEED)

    Returns:
        Same as swe_nod_aps_ut: (xnasc, xndsc, xperi, xaphe)

    Example:
        >>> from libephemeris import swe_nod_aps, SE_JUPITER, SE_NODBIT_OSCU
        >>> nasc, ndsc, peri, aphe = swe_nod_aps(2451545.0, SE_JUPITER, SE_NODBIT_OSCU)
    """
    ts = get_timescale()
    t = ts.tt_jd(tjd_et)
    return _calc_nod_aps(t, ipl, iflag, method)


class HeliocentricNodApsWarning(UserWarning):
    """Warning for heliocentric nod_aps methodology differences.

    This warning is issued when calculating nodes and apsides for inner planets
    (Mercury, Venus) because LibEphemeris uses heliocentric mean orbital elements
    while pyswisseph uses a geocentric interpretation, causing large apparent
    differences (up to 250 degrees).

    Both approaches are astronomically valid and answer different questions:
    - Heliocentric: Where does the orbit cross the ecliptic as seen from the Sun?
    - Geocentric: Where does the planet appear to cross the ecliptic from Earth?

    For astrological use, check which interpretation matches your tradition.
    """

    pass


def _calc_nod_aps(
    t, ipl: int, iflag: int, method: int
) -> Tuple[PosTuple, PosTuple, PosTuple, PosTuple]:
    """
    Calculate orbital nodes and apsides using heliocentric mean orbital elements.

    .. warning:: Methodological Difference from pyswisseph

        This function uses **heliocentric mean orbital elements** from Standish
        (1992) JPL/IERS tables. This is a fundamentally different approach from
        pyswisseph, which uses a **geocentric interpretation**.

        **Impact for inner planets (Mercury, Venus):**
        Differences can be up to ~250 degrees because the heliocentric and
        geocentric orbital planes are oriented very differently relative to Earth.

        **Impact for outer planets (Mars, Jupiter, Saturn, etc.):**
        Differences are small (<1 degree) because the heliocentric/geocentric
        distinction diminishes with distance from the Sun.

        **Which is "correct"?**
        Both approaches are astronomically valid - they answer different questions:

        - **Heliocentric (LibEphemeris)**: "Where does the planet's orbit cross
          the ecliptic plane as seen from the Sun?" This gives the true orbital
          node of the planet in its heliocentric orbit.

        - **Geocentric (pyswisseph)**: "Where does the planet appear to
          cross the ecliptic as seen from Earth?" This is the apparent crossing
          point from Earth's perspective.

        For outer planets, both approaches give similar results. For inner planets,
        the geocentric interpretation is more commonly used in astrological
        traditions that focus on Earth-centered observations.

    Uses mean orbital elements from JPL/IERS tables (Standish, 1992) to compute
    the positions of orbital nodes and apsides. The mean elements are given as
    polynomial expansions in time from J2000.0.

    For SE_NODBIT_MEAN: Uses mean orbital elements (averaged over perturbations)
    For SE_NODBIT_OSCU: Uses osculating elements from current state vector

    Args:
        t: Skyfield Time object
        ipl: Planet ID (SE_MERCURY, SE_VENUS, SE_MARS, etc.)
        iflag: Calculation flags
        method: Node/apse calculation method (SE_NODBIT_MEAN or SE_NODBIT_OSCU)

    Returns:
        Tuple of (ascending_node, descending_node, perihelion, aphelion)
        Each element is a PosTuple: (longitude, latitude, distance, dlon, dlat, ddist)

    See Also:
        - PRECISION.md: "Planetary Nodes and Apsides" section for full documentation
        - swe_nod_aps(): Public API wrapper for Ephemeris Time
        - swe_nod_aps_ut(): Public API wrapper for Universal Time

    References:
        - Standish, E.M. (1992). "Keplerian Elements for Approximate Positions
          of the Major Planets". JPL/IERS.
    """
    # Zero position for unsupported bodies
    zero_pos: PosTuple = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)

    # Get planet name from map
    if ipl not in _PLANET_MAP:
        return (zero_pos, zero_pos, zero_pos, zero_pos)

    # Sun and Earth don't have orbital nodes/apsides in heliocentric sense
    if ipl in [SE_SUN, SE_EARTH]:
        return (zero_pos, zero_pos, zero_pos, zero_pos)

    # Warn for inner planets about methodological differences with pyswisseph
    if ipl in [SE_MERCURY, SE_VENUS]:
        planet_name = _PLANET_NAMES.get(ipl, f"Planet {ipl}")
        warnings.warn(
            f"nod_aps for {planet_name}: LibEphemeris uses heliocentric mean orbital "
            f"elements (Standish 1992) while pyswisseph uses geocentric "
            f"interpretation. This can cause differences up to ~250 degrees for inner "
            f"planets. Both approaches are valid - see PRECISION.md for details.",
            HeliocentricNodApsWarning,
            stacklevel=3,  # Point to caller of swe_nod_aps/swe_nod_aps_ut
        )

    # Mean orbital elements at J2000.0 and their rates per Julian century
    # From "Keplerian Elements for Approximate Positions of the Major Planets"
    # (Standish, E.M., 1992, JPL/IERS)
    # Format: {planet_id: (a, e, i, L, varpi, Omega, da, de, di, dL, dvarpi, dOmega)}
    # where L = mean longitude, varpi = longitude of perihelion, Omega = longitude of ascending node
    # Rates are per Julian century from J2000.0
    # Values in AU, degrees, and AU/century, degrees/century
    MEAN_ELEMENTS = {
        SE_MERCURY: (
            0.38709927,
            0.20563593,
            7.00497902,
            252.25032350,
            77.45779628,
            48.33076593,
            0.00000037,
            0.00001906,
            -0.00594749,
            149472.67411175,
            0.16047689,
            -0.12534081,
        ),
        SE_VENUS: (
            0.72333566,
            0.00677672,
            3.39467605,
            181.97909950,
            131.60246718,
            76.67984255,
            0.00000390,
            -0.00004107,
            -0.00078890,
            58517.81538729,
            0.00268329,
            -0.27769418,
        ),
        SE_MARS: (
            1.52371034,
            0.09339410,
            1.84969142,
            -4.55343205,
            -23.94362959,
            49.55953891,
            0.00001847,
            0.00007882,
            -0.00813131,
            19140.30268499,
            0.44441088,
            -0.29257343,
        ),
        SE_JUPITER: (
            5.20288700,
            0.04838624,
            1.30439695,
            34.39644051,
            14.72847983,
            100.47390909,
            -0.00011607,
            -0.00013253,
            -0.00183714,
            3034.74612775,
            0.21252668,
            0.20469106,
        ),
        SE_SATURN: (
            9.53667594,
            0.05386179,
            2.48599187,
            49.95424423,
            92.59887831,
            113.66242448,
            -0.00125060,
            -0.00050991,
            0.00193609,
            1222.49362201,
            -0.41897216,
            -0.28867794,
        ),
        SE_URANUS: (
            19.18916464,
            0.04725744,
            0.77263783,
            313.23810451,
            170.95427630,
            74.01692503,
            -0.00196176,
            -0.00004397,
            -0.00242939,
            428.48202785,
            0.40805281,
            0.04240589,
        ),
        SE_NEPTUNE: (
            30.06992276,
            0.00859048,
            1.77004347,
            -55.12002969,
            44.96476227,
            131.78422574,
            0.00026291,
            0.00005105,
            0.00035372,
            218.45945325,
            -0.32241464,
            -0.00508664,
        ),
        SE_PLUTO: (
            39.48211675,
            0.24882730,
            17.14001206,
            238.92903833,
            224.06891629,
            110.30393684,
            -0.00031596,
            0.00005170,
            0.00004818,
            145.20780515,
            -0.04062942,
            -0.01183482,
        ),
        SE_MOON: (
            # Moon uses different parameters - orbital elements around Earth
            # a (in AU), e, i (to ecliptic), L, varpi, Omega
            # Values for Moon are geocentric, not heliocentric
            0.00256955529,
            0.0549,
            5.145,
            0.0,
            0.0,
            0.0,  # Approximate values
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
        ),
    }

    # Check if we have mean elements for this planet
    if ipl not in MEAN_ELEMENTS:
        # For unsupported bodies, use osculating elements
        return _calc_nod_aps_osculating(t, ipl, iflag)

    # Calculate Julian centuries from J2000.0
    T = (t.tt - 2451545.0) / 36525.0

    # Get mean elements
    a0, e0, i0, L0, varpi0, Omega0, da, de, di, dL, dvarpi, dOmega = MEAN_ELEMENTS[ipl]

    # Calculate current mean elements
    a = a0 + da * T  # Semi-major axis
    e = e0 + de * T  # Eccentricity
    incl = i0 + di * T  # Inclination (degrees)
    L = (L0 + dL * T) % 360.0  # Mean longitude (degrees)
    varpi = (varpi0 + dvarpi * T) % 360.0  # Longitude of perihelion (degrees)
    Omega = (Omega0 + dOmega * T) % 360.0  # Longitude of ascending node (degrees)

    # Argument of perihelion: omega = varpi - Omega
    omega = varpi - Omega
    if omega < 0:
        omega += 360.0

    # Convert inclination and omega to radians for latitude calculation
    incl_rad = math.radians(incl)
    omega_rad = math.radians(omega)

    # Distances at nodes and apsides
    # Semi-latus rectum: p = a * (1 - e^2)
    p = a * (1 - e**2)

    # Distance at ascending node (true anomaly = -omega)
    nu_asc = -omega_rad
    r_asc = (
        p / (1 + e * math.cos(nu_asc)) if abs(1 + e * math.cos(nu_asc)) > 1e-10 else a
    )

    # Distance at descending node (true anomaly = pi - omega)
    nu_dsc = math.pi - omega_rad
    r_dsc = (
        p / (1 + e * math.cos(nu_dsc)) if abs(1 + e * math.cos(nu_dsc)) > 1e-10 else a
    )

    # Distance at perihelion: r_peri = a * (1 - e)
    r_peri = a * (1 - e)

    # Distance at aphelion: r_aphe = a * (1 + e)
    r_aphe = a * (1 + e)

    # Latitude at perihelion (true anomaly = 0, measured from ascending node)
    # latitude = arcsin(sin(i) * sin(omega))
    lat_peri = math.degrees(math.asin(math.sin(incl_rad) * math.sin(omega_rad)))

    # Latitude at aphelion (true anomaly = pi)
    # latitude = arcsin(sin(i) * sin(omega + pi)) = -arcsin(sin(i) * sin(omega))
    lat_aphe = -lat_peri

    # Normalize angles
    Omega_deg = Omega % 360.0
    Omega_dsc_deg = (Omega + 180.0) % 360.0
    varpi_deg = varpi % 360.0
    aphe_deg = (varpi + 180.0) % 360.0

    # Create position tuples (lon, lat, dist, dlon, dlat, ddist)
    # For mean elements, the latitude of nodes is 0 (by definition)
    xnasc: PosTuple = (Omega_deg, 0.0, r_asc, 0.0, 0.0, 0.0)
    xndsc: PosTuple = (Omega_dsc_deg, 0.0, r_dsc, 0.0, 0.0, 0.0)
    xperi: PosTuple = (varpi_deg, lat_peri, r_peri, 0.0, 0.0, 0.0)
    xaphe: PosTuple = (aphe_deg, lat_aphe, r_aphe, 0.0, 0.0, 0.0)

    return (xnasc, xndsc, xperi, xaphe)


def swe_get_orbital_elements(
    tjd_et: float, ipl: int, iflag: int
) -> Tuple[Tuple[float, ...], int]:
    """
    Calculate Keplerian orbital elements for a celestial body.

    Reference API compatible function.

    This function computes the osculating (instantaneous) orbital elements
    for a planet at a given time. The elements describe the elliptical orbit
    that the planet would follow if all perturbations ceased at that moment.

    Args:
        tjd_et: Julian Day in Ephemeris Time (TT/ET)
        ipl: Planet/body ID (SE_SUN, SE_MOON, etc.)
        iflag: Calculation flags (SEFLG_HELCTR for heliocentric, etc.)

    Returns:
        Tuple containing:
            - Tuple of 17 orbital elements:
                [0] a: Semi-major axis (AU)
                [1] e: Eccentricity (0=circle, <1=ellipse, 1=parabola, >1=hyperbola)
                [2] i: Inclination (degrees, relative to ecliptic)
                [3] Omega: Longitude of ascending node (degrees)
                [4] omega: Argument of perihelion (degrees)
                [5] M: Mean anomaly (degrees)
                [6] nu: True anomaly (degrees)
                [7] E: Eccentric anomaly (degrees)
                [8] L: Mean longitude (degrees)
                [9] varpi: Longitude of perihelion (degrees)
                [10] n: Mean daily motion (degrees/day)
                [11] q: Perihelion distance (AU)
                [12] Q: Aphelion distance (AU)
                [13] P: Orbital period (tropical years)
                [14] T: Time of perihelion passage (JD)
                [15] Ps: Synodic period (days)
                [16] r: Current heliocentric distance (AU)
            - Return flag: iflag value on success

    Example:
        >>> from libephemeris import get_orbital_elements, SE_MARS, SEFLG_HELCTR
        >>> elements, flag = get_orbital_elements(2451545.0, SE_MARS, SEFLG_HELCTR)
        >>> a, e, i = elements[0], elements[1], elements[2]
        >>> print(f"Mars: a={a:.4f} AU, e={e:.4f}, i={i:.4f}°")

    Note:
        - For heliocentric calculations (default), elements are relative to the Sun
        - Moon's elements are geocentric (relative to Earth)
        - Elements change constantly due to perturbations from other planets
    """
    ts = get_timescale()
    t = ts.tt_jd(tjd_et)
    return _calc_orbital_elements(t, ipl, iflag)


def swe_get_orbital_elements_ut(
    tjd_ut: float, ipl: int, iflag: int
) -> Tuple[Tuple[float, ...], int]:
    """
    Calculate Keplerian orbital elements for Universal Time.

    Reference API compatible function. Similar to swe_get_orbital_elements()
    but takes Universal Time instead of Ephemeris Time.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_SUN, SE_MOON, etc.)
        iflag: Calculation flags

    Returns:
        Same as swe_get_orbital_elements: (elements_tuple, retflag)

    Example:
        >>> elements, flag = get_orbital_elements_ut(2451545.0, SE_JUPITER, 0)
        >>> print(f"Jupiter semi-major axis: {elements[0]:.4f} AU")
    """
    ts = get_timescale()
    t = ts.ut1_jd(tjd_ut)
    return _calc_orbital_elements(t, ipl, iflag)


def _calc_orbital_elements(t, ipl: int, iflag: int) -> Tuple[Tuple[float, ...], int]:
    """
    Internal function to calculate orbital elements.

    Computes osculating Keplerian elements from the body's current position
    and velocity vectors. Uses state vectors from JPL ephemeris.

    Args:
        t: Skyfield Time object
        ipl: Planet ID
        iflag: Calculation flags

    Returns:
        Tuple of (17-element tuple, flags)
    """
    planets = get_planets()
    zero_elements = tuple([0.0] * 17)

    # Sun and Earth don't have heliocentric orbital elements
    if ipl == SE_SUN:
        return (zero_elements, iflag)

    # Get target and center bodies
    if ipl not in _PLANET_MAP:
        return (zero_elements, iflag)

    target_name = _PLANET_MAP[ipl]
    # Try planet center first, fall back to barycenter if not available
    try:
        target = planets[target_name]
    except KeyError:
        if target_name in _PLANET_FALLBACK:
            target = planets[_PLANET_FALLBACK[target_name]]
        else:
            raise

    # For Moon, use geocentric orbit (around Earth)
    if ipl == SE_MOON:
        center = planets["earth"]
        # GM_Earth in AU^3/day^2 (from solar mass ratio)
        GM = 0.01720209895**2 / 332946.0
    else:
        center = planets["sun"]
        # GM_sun in AU^3/day^2
        GM = 0.01720209895**2

    # Get heliocentric (or geocentric for Moon) position and velocity
    center_pos = center.at(t)
    target_pos = target.at(t)

    # Position and velocity vectors in ICRS (equatorial) frame
    r_icrs = target_pos.position.au - center_pos.position.au
    v_icrs = target_pos.velocity.au_per_d - center_pos.velocity.au_per_d

    # Convert from ICRS (equatorial) to ecliptic coordinates
    eps = 23.4392911  # Mean obliquity at J2000.0
    eps_rad = math.radians(eps)
    cos_eps = math.cos(eps_rad)
    sin_eps = math.sin(eps_rad)

    # Rotate position to ecliptic
    x = r_icrs[0]
    y = r_icrs[1] * cos_eps + r_icrs[2] * sin_eps
    z = -r_icrs[1] * sin_eps + r_icrs[2] * cos_eps

    # Rotate velocity to ecliptic
    vx = v_icrs[0]
    vy = v_icrs[1] * cos_eps + v_icrs[2] * sin_eps
    vz = -v_icrs[1] * sin_eps + v_icrs[2] * cos_eps

    # Calculate orbital elements from state vectors
    r_mag = math.sqrt(x**2 + y**2 + z**2)
    v_mag = math.sqrt(vx**2 + vy**2 + vz**2)

    # Radial velocity
    r_dot_v = x * vx + y * vy + z * vz
    v_r = r_dot_v / r_mag

    # Specific angular momentum vector h = r x v
    hx = y * vz - z * vy
    hy = z * vx - x * vz
    hz = x * vy - y * vx
    h_mag = math.sqrt(hx**2 + hy**2 + hz**2)

    # Node vector n = k x h (k is unit vector in z-direction)
    nx = -hy
    ny = hx
    n_mag = math.sqrt(nx**2 + ny**2)

    # Eccentricity vector e = (v x h)/GM - r/|r|
    e_coef = v_mag**2 / GM - 1.0 / r_mag
    rv_coef = r_dot_v / GM
    ex = e_coef * x - rv_coef * vx
    ey = e_coef * y - rv_coef * vy
    ez = e_coef * z - rv_coef * vz
    e_mag = math.sqrt(ex**2 + ey**2 + ez**2)

    # Specific orbital energy
    epsilon = v_mag**2 / 2 - GM / r_mag

    # Semi-major axis
    if abs(epsilon) > 1e-10:
        a = -GM / (2 * epsilon)
    else:
        a = float("inf")  # Parabolic orbit

    # Handle near-circular and near-equatorial orbits
    e = e_mag
    if e < 1e-10:
        e = 0.0

    # Inclination
    if h_mag > 1e-10:
        i = math.acos(max(-1.0, min(1.0, hz / h_mag)))
    else:
        i = 0.0

    # Longitude of ascending node (Omega)
    if n_mag > 1e-10:
        Omega = math.atan2(ny, nx)
        if Omega < 0:
            Omega += 2 * math.pi
    else:
        Omega = 0.0

    # Argument of perihelion (omega)
    if n_mag > 1e-10 and e_mag > 1e-10:
        n_dot_e = nx * ex + ny * ey
        cos_omega = n_dot_e / (n_mag * e_mag)
        cos_omega = max(-1.0, min(1.0, cos_omega))
        omega = math.acos(cos_omega)
        if ez < 0:
            omega = 2 * math.pi - omega
    elif e_mag > 1e-10:
        # Near-equatorial orbit: measure from x-axis
        omega = math.atan2(ey, ex)
        if omega < 0:
            omega += 2 * math.pi
    else:
        omega = 0.0

    # True anomaly (nu)
    if e_mag > 1e-10:
        e_dot_r = ex * x + ey * y + ez * z
        cos_nu = e_dot_r / (e_mag * r_mag)
        cos_nu = max(-1.0, min(1.0, cos_nu))
        nu = math.acos(cos_nu)
        if r_dot_v < 0:
            nu = 2 * math.pi - nu
    else:
        # Circular orbit: measure from ascending node or x-axis
        if n_mag > 1e-10:
            n_dot_r = nx * x + ny * y
            cos_nu = n_dot_r / (n_mag * r_mag)
            cos_nu = max(-1.0, min(1.0, cos_nu))
            nu = math.acos(cos_nu)
            if z < 0:
                nu = 2 * math.pi - nu
        else:
            nu = math.atan2(y, x)
            if nu < 0:
                nu += 2 * math.pi

    # Eccentric anomaly (E) from true anomaly
    if e < 1.0 and e > 1e-10:
        tan_half_nu = math.tan(nu / 2)
        sqrt_term = math.sqrt((1 - e) / (1 + e))
        E = 2 * math.atan(sqrt_term * tan_half_nu)
        if E < 0:
            E += 2 * math.pi
    else:
        E = nu  # For circular orbits, E = nu

    # Mean anomaly (M) from eccentric anomaly (Kepler's equation)
    if e < 1.0:
        M = E - e * math.sin(E)
        if M < 0:
            M += 2 * math.pi
    else:
        M = E  # For circular or hyperbolic orbits

    # Mean longitude (L)
    L = (Omega + omega + M) % (2 * math.pi)

    # Longitude of perihelion (varpi)
    varpi = (Omega + omega) % (2 * math.pi)

    # Mean daily motion (n) in radians/day
    if a > 0 and a < float("inf"):
        n = math.sqrt(GM / (a**3))  # radians/day
    else:
        n = 0.0

    # Perihelion and aphelion distances
    if a > 0 and a < float("inf"):
        q = a * (1 - e)  # Perihelion
        Q = a * (1 + e)  # Aphelion
    else:
        q = r_mag  # For parabolic/hyperbolic
        Q = float("inf")

    # Orbital period in tropical years
    # 1 tropical year = 365.24219 days
    if n > 0:
        P_days = 2 * math.pi / n
        P_years = P_days / 365.24219
    else:
        P_years = float("inf")

    # Time of perihelion passage (T)
    # T = t - M/n where M is in radians and n is radians/day
    if n > 0:
        T_jd = t.tt - M / n
    else:
        T_jd = 0.0

    # Synodic period (for planets relative to Earth)
    # P_syn = |1 / (1/P_planet - 1/P_earth)|
    P_earth_days = 365.24219  # Earth's orbital period in days
    if P_years > 0 and P_years < float("inf") and ipl not in [SE_EARTH, SE_MOON]:
        P_planet_days = P_years * 365.24219
        denom = abs(1.0 / P_planet_days - 1.0 / P_earth_days)
        if denom > 1e-10:
            P_syn = 1.0 / denom
        else:
            P_syn = float("inf")
    else:
        P_syn = 0.0  # Not applicable for Earth or Moon

    # Current heliocentric distance
    r_current = r_mag

    # Convert angles to degrees
    i_deg = math.degrees(i)
    Omega_deg = math.degrees(Omega)
    omega_deg = math.degrees(omega)
    nu_deg = math.degrees(nu)
    E_deg = math.degrees(E)
    M_deg = math.degrees(M)
    L_deg = math.degrees(L)
    varpi_deg = math.degrees(varpi)
    n_deg = math.degrees(n)  # degrees/day

    # Build the 17-element tuple
    elements = (
        a,  # [0] Semi-major axis (AU)
        e,  # [1] Eccentricity
        i_deg,  # [2] Inclination (degrees)
        Omega_deg,  # [3] Longitude of ascending node (degrees)
        omega_deg,  # [4] Argument of perihelion (degrees)
        M_deg,  # [5] Mean anomaly (degrees)
        nu_deg,  # [6] True anomaly (degrees)
        E_deg,  # [7] Eccentric anomaly (degrees)
        L_deg,  # [8] Mean longitude (degrees)
        varpi_deg,  # [9] Longitude of perihelion (degrees)
        n_deg,  # [10] Mean daily motion (degrees/day)
        q,  # [11] Perihelion distance (AU)
        Q,  # [12] Aphelion distance (AU)
        P_years,  # [13] Orbital period (tropical years)
        T_jd,  # [14] Time of perihelion passage (JD)
        P_syn,  # [15] Synodic period (days)
        r_current,  # [16] Current heliocentric distance (AU)
    )

    return (elements, iflag)


def swe_orbit_max_min_true_distance(
    tjd_ut: float, ipl: int, iflag: int
) -> Tuple[float, float]:
    """
    Calculate the minimum and maximum geocentric distances during a planet's orbit.

    Reference API compatible function.

    This function computes the minimum and maximum true distances from Earth
    that a planet can reach during its orbital motion. These distances correspond
    to the planet's perigee (closest approach) and apogee (farthest distance)
    relative to Earth.

    For outer planets (Mars-Pluto), the minimum distance occurs around opposition
    and the maximum around conjunction with the Sun.

    For inner planets (Mercury, Venus), the minimum distance occurs near inferior
    conjunction and the maximum near superior conjunction.

    Note: The pyswisseph API returns (min_distance, max_distance).

    Args:
        tjd_ut: Julian Day in Universal Time (UT1) - used to determine current
                orbital elements, though the min/max are characteristic of the orbit.
        ipl: Planet/body ID (SE_SUN, SE_MOON, etc.)
        iflag: Calculation flags (currently unused, for API compatibility)

    Returns:
        Tuple of (min_distance, max_distance) in AU

    Example:
        >>> from libephemeris import orbit_max_min_true_distance, SE_MARS
        >>> min_dist, max_dist = orbit_max_min_true_distance(2451545.0, SE_MARS, 0)
        >>> print(f"Mars distance range: {min_dist:.4f} - {max_dist:.4f} AU")

    Note:
        - For geocentric calculations, these represent the range of Earth-planet
          distances possible during the synodic cycle.
        - The Moon's distance is calculated as geocentric (Earth-Moon distance).
        - Sun returns (0.0, 0.0) as it has no meaningful geocentric distance range.
    """
    ts = get_timescale()
    t = ts.ut1_jd(tjd_ut)
    return _calc_orbit_max_min_true_distance(t, ipl, iflag)


def _calc_orbit_max_min_true_distance(t, ipl: int, iflag: int) -> Tuple[float, float]:
    """
    Internal function to calculate min/max geocentric distances.

    For planets, this computes the theoretical minimum and maximum distances
    from Earth during the orbital cycle. This is derived from the orbital
    elements of both the planet and Earth.

    Algorithm:
        For outer planets (Mars-Pluto):
            - min_distance ≈ a_planet - a_earth (at opposition, both at same side)
            - max_distance ≈ a_planet + a_earth (at conjunction, opposite sides)

        For inner planets (Mercury, Venus):
            - min_distance ≈ a_earth - a_planet (at inferior conjunction)
            - max_distance ≈ a_earth + a_planet (at superior conjunction)

        Corrections are applied for eccentricity of both orbits.

    Args:
        t: Skyfield Time object
        ipl: Planet ID
        iflag: Calculation flags

    Returns:
        Tuple of (min_distance, max_distance) in AU
    """
    # Sun has no geocentric distance variation
    if ipl == SE_SUN:
        return (0.0, 0.0)

    # Earth has no geocentric distance (it's the observer)
    if ipl == SE_EARTH:
        return (0.0, 0.0)

    # Moon - use geocentric orbit parameters
    if ipl == SE_MOON:
        # Moon's mean distance and eccentricity
        # Semi-major axis: ~384,400 km = 0.00257 AU
        # Eccentricity: ~0.0549
        a_moon = 0.00256955529  # AU (same as in MEAN_ELEMENTS)
        e_moon = 0.0549
        min_dist = a_moon * (1 - e_moon)  # Perigee
        max_dist = a_moon * (1 + e_moon)  # Apogee
        return (min_dist, max_dist)

    # For planets, we need orbital elements
    # Get orbital elements from the existing function
    elements, _ = _calc_orbital_elements(t, ipl, iflag)

    if elements[0] == 0.0:  # Invalid planet
        return (0.0, 0.0)

    # Extract planet's semi-major axis and eccentricity
    a_planet = elements[0]  # Semi-major axis in AU
    e_planet = elements[1]  # Eccentricity

    # Earth's orbital parameters (mean values)
    a_earth = 1.00000261  # Semi-major axis in AU
    e_earth = 0.01671123  # Eccentricity

    # Perihelion and aphelion distances
    r_planet_min = a_planet * (1 - e_planet)  # Planet perihelion
    r_planet_max = a_planet * (1 + e_planet)  # Planet aphelion
    r_earth_min = a_earth * (1 - e_earth)  # Earth perihelion
    r_earth_max = a_earth * (1 + e_earth)  # Earth aphelion

    # Determine if inner or outer planet
    if a_planet < a_earth:
        # Inner planet (Mercury, Venus)
        # Minimum distance: at inferior conjunction when planet is at aphelion
        # and Earth is at perihelion (closest possible approach)
        # Actually, minimum occurs when planet is between Earth and Sun
        # min ≈ r_earth - r_planet (when aligned, planet between)
        # max ≈ r_earth + r_planet (when aligned, Sun between)
        min_dist = abs(r_earth_min - r_planet_max)
        max_dist = r_earth_max + r_planet_max
    else:
        # Outer planet (Mars, Jupiter, etc.)
        # Minimum distance: at opposition when both are aligned on same side of Sun
        # max ≈ r_planet + r_earth (at conjunction, Sun between)
        # min ≈ r_planet - r_earth (at opposition, same side)
        min_dist = abs(r_planet_min - r_earth_max)
        max_dist = r_planet_max + r_earth_max

    return (min_dist, max_dist)


def _calc_nod_aps_osculating(
    t, ipl: int, iflag: int
) -> Tuple[PosTuple, PosTuple, PosTuple, PosTuple]:
    """
    Calculate orbital nodes and apsides using osculating (instantaneous) elements.

    Uses the planet's current position and velocity to compute orbital elements.
    These are the "true" instantaneous orbital parameters that include short-term
    perturbations from other planets.
    """
    planets = get_planets()
    zero_pos: PosTuple = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)

    target_name = _PLANET_MAP.get(ipl)
    if not target_name:
        return (zero_pos, zero_pos, zero_pos, zero_pos)

    # Try planet center first, fall back to barycenter if not available
    try:
        target = planets[target_name]
    except KeyError:
        if target_name in _PLANET_FALLBACK:
            target = planets[_PLANET_FALLBACK[target_name]]
        else:
            raise
    sun = planets["sun"]

    # Get heliocentric position and velocity
    sun_pos = sun.at(t)
    target_pos = target.at(t)

    # Heliocentric vectors in AU and AU/day (ICRS frame)
    r_icrs = target_pos.position.au - sun_pos.position.au
    v_icrs = target_pos.velocity.au_per_d - sun_pos.velocity.au_per_d

    # Convert from ICRS (equatorial) to ecliptic coordinates
    eps = 23.4392911  # Mean obliquity at J2000.0
    eps_rad = math.radians(eps)
    cos_eps = math.cos(eps_rad)
    sin_eps = math.sin(eps_rad)

    # Rotate to ecliptic
    x_ecl = r_icrs[0]
    y_ecl = r_icrs[1] * cos_eps + r_icrs[2] * sin_eps
    z_ecl = -r_icrs[1] * sin_eps + r_icrs[2] * cos_eps

    vx_ecl = v_icrs[0]
    vy_ecl = v_icrs[1] * cos_eps + v_icrs[2] * sin_eps
    vz_ecl = -v_icrs[1] * sin_eps + v_icrs[2] * cos_eps

    # GM_sun in AU^3/day^2
    GM_sun = 0.01720209895**2

    r_mag = math.sqrt(x_ecl**2 + y_ecl**2 + z_ecl**2)
    v_mag = math.sqrt(vx_ecl**2 + vy_ecl**2 + vz_ecl**2)

    # Angular momentum
    hx = y_ecl * vz_ecl - z_ecl * vy_ecl
    hy = z_ecl * vx_ecl - x_ecl * vz_ecl
    hz = x_ecl * vy_ecl - y_ecl * vx_ecl
    h_mag = math.sqrt(hx**2 + hy**2 + hz**2)

    # Node vector
    nx = -hy
    ny = hx
    n_mag = math.sqrt(nx**2 + ny**2)

    # Eccentricity vector
    r_dot_v = x_ecl * vx_ecl + y_ecl * vy_ecl + z_ecl * vz_ecl
    coef1 = v_mag**2 / GM_sun - 1.0 / r_mag
    coef2 = r_dot_v / GM_sun

    ex = coef1 * x_ecl - coef2 * vx_ecl
    ey = coef1 * y_ecl - coef2 * vy_ecl
    ez = coef1 * z_ecl - coef2 * vz_ecl
    e_mag = math.sqrt(ex**2 + ey**2 + ez**2)

    # Semi-major axis
    a = 1.0 / (2.0 / r_mag - v_mag**2 / GM_sun)

    # Inclination
    incl = math.acos(hz / h_mag) if h_mag > 0 else 0.0

    # Longitude of ascending node
    if n_mag > 1e-10:
        Omega = math.atan2(ny, nx)
        if Omega < 0:
            Omega += 2 * math.pi
    else:
        Omega = 0.0

    # Argument of perihelion
    if n_mag > 1e-10 and e_mag > 1e-10:
        n_dot_e = nx * ex + ny * ey
        cos_omega = n_dot_e / (n_mag * e_mag)
        cos_omega = max(-1.0, min(1.0, cos_omega))
        omega = math.acos(cos_omega)
        if ez < 0:
            omega = 2 * math.pi - omega
    else:
        omega = 0.0

    # Longitude of perihelion
    varpi = Omega + omega

    # Calculate positions
    Omega_deg = math.degrees(Omega) % 360.0
    varpi_deg = math.degrees(varpi) % 360.0

    # Distances
    if e_mag < 1.0:
        p = a * (1 - e_mag**2)
        r_asc = (
            p / (1 + e_mag * math.cos(-omega))
            if abs(1 + e_mag * math.cos(-omega)) > 1e-10
            else a
        )
        r_dsc = (
            p / (1 + e_mag * math.cos(math.pi - omega))
            if abs(1 + e_mag * math.cos(math.pi - omega)) > 1e-10
            else a
        )
    else:
        r_asc = r_dsc = a

    r_peri = a * (1 - e_mag)
    r_aphe = a * (1 + e_mag)

    # Latitudes at apsides
    lat_peri = (
        math.degrees(math.asin(math.sin(incl) * math.sin(omega))) if incl > 0 else 0.0
    )
    lat_aphe = -lat_peri

    xnasc: PosTuple = (Omega_deg, 0.0, r_asc, 0.0, 0.0, 0.0)
    xndsc: PosTuple = ((Omega_deg + 180.0) % 360.0, 0.0, r_dsc, 0.0, 0.0, 0.0)
    xperi: PosTuple = (varpi_deg, lat_peri, r_peri, 0.0, 0.0, 0.0)
    xaphe: PosTuple = ((varpi_deg + 180.0) % 360.0, lat_aphe, r_aphe, 0.0, 0.0, 0.0)

    return (xnasc, xndsc, xperi, xaphe)


# =============================================================================
# PLANETARY PHENOMENA: Phase, Elongation, Magnitude
# =============================================================================

# Physical equatorial radii of celestial bodies in kilometers
# Sources: IAU 2015 nominal values, NASA Planetary Fact Sheet
# These are used to calculate apparent angular diameters
_BODY_RADIUS_KM = {
    SE_SUN: 695700.0,  # Solar radius (IAU 2015 nominal)
    SE_MOON: 1737.4,  # Lunar mean radius
    SE_MERCURY: 2439.7,  # Mercury equatorial radius
    SE_VENUS: 6051.8,  # Venus equatorial radius
    SE_MARS: 3396.2,  # Mars equatorial radius
    SE_JUPITER: 71492.0,  # Jupiter equatorial radius
    SE_SATURN: 60268.0,  # Saturn equatorial radius (disk only, excludes rings)
    SE_URANUS: 25559.0,  # Uranus equatorial radius
    SE_NEPTUNE: 24764.0,  # Neptune equatorial radius
    SE_PLUTO: 1188.3,  # Pluto mean radius
}

# 1 AU in kilometers (IAU 2012 definition)
_AU_KM = 149597870.7

# Conversion factor: radians to arcseconds
_RAD_TO_ARCSEC = 206264.80624709636  # (180/pi) * 3600


def _calc_apparent_diameter(radius_km: float, distance_au: float) -> float:
    """
    Calculate apparent angular diameter in arcseconds.

    Uses the small-angle approximation which is accurate for all solar system bodies
    as seen from Earth (maximum angular size is ~0.5 degrees for Sun/Moon).

    The formula is:
        diameter_arcsec = 2 * radius_km / distance_km * RAD_TO_ARCSEC
                        = 2 * radius_km / (distance_au * AU_KM) * RAD_TO_ARCSEC

    Args:
        radius_km: Physical radius of the body in kilometers
        distance_au: Distance from observer to body in AU

    Returns:
        Apparent angular diameter in arcseconds
    """
    if distance_au <= 0:
        return 0.0
    distance_km = distance_au * _AU_KM
    # Angular diameter = 2 * arctan(radius/distance) ≈ 2 * radius/distance (small angle)
    return 2.0 * radius_km / distance_km * _RAD_TO_ARCSEC


# Visual magnitude parameters for outer planets
# Using simplified formula: V = V(1,0) + 5*log10(r*d) + phase_correction
# For Mercury, Venus, Mars, Jupiter, Saturn we use Mallama 2018 formulas
# (implemented directly in _calc_planet_magnitude)
_PLANET_MAG_PARAMS = {
    # Mercury, Venus, Mars, Jupiter, Saturn, Pluto use Mallama 2018 formulas
    # (implemented directly in _calc_planet_magnitude)
    SE_URANUS: (-7.19, 0.002, 0.0, 0.0),  # Uranus
    SE_NEPTUNE: (-6.87, 0.0, 0.0, 0.0),  # Neptune: nearly constant
    # Pluto uses dedicated Mallama 2018 formula below
}

# J2000 epoch for Saturn ring calculations
_J2000 = 2451545.0

# Mean Earth-Moon distance in AU (384,400 km)
_MEAN_MOON_DISTANCE_AU = 384400.0 / 149597870.7


def _calc_moon_magnitude_hapke(phase_angle: float, distance_au: float) -> float:
    """
    Calculate Moon's visual magnitude using a simplified Hapke photometric model.

    This implementation is based on the Hapke bidirectional reflectance model
    with parameters derived from USNO lunar photometry data and Allen's
    Astrophysical Quantities. The model accounts for:

    1. Phase darkening: The Moon gets dimmer as phase angle increases
    2. Opposition surge: Enhanced brightness at small phase angles (<7°)
       due to shadow hiding and coherent backscattering
    3. Distance correction: Brightness varies with Earth-Moon distance

    The phase function uses polynomial coefficients fitted to match observed
    lunar brightness curves within ±0.1 mag across all phases.

    Reference values:
    - Full Moon at mean distance: V = -12.74 mag (Allen's Astrophysical Quantities)
    - Full Moon at perigee: V ≈ -12.90 mag
    - Full Moon at apogee: V ≈ -12.55 mag

    Args:
        phase_angle: Sun-Moon-Earth angle in degrees (0° = full, 180° = new)
        distance_au: Earth-Moon distance in AU

    Returns:
        Visual magnitude (more negative = brighter)

    References:
        - Hapke, B. (1986) "Bidirectional reflectance spectroscopy"
        - Allen's Astrophysical Quantities, 4th ed., Table 12.16
        - Kieffer, H.H. & Stone, T.C. (2005) "The spectral irradiance of the Moon"
        - Lane, A.P. & Irvine, W.M. (1973) "Lunar photometry"
    """
    # Full Moon magnitude at mean distance (Allen's Astrophysical Quantities)
    V0 = -12.74

    # Distance correction: magnitude scales with distance squared
    # delta_m = 5 * log10(d / d_mean)
    if distance_au > 0:
        dist_correction = 5.0 * math.log10(distance_au / _MEAN_MOON_DISTANCE_AU)
    else:
        dist_correction = 0.0

    # Phase angle in radians for trigonometric calculations
    alpha = phase_angle
    alpha_rad = math.radians(alpha)

    # Opposition surge (Hapke B_S0 and h_s parameters)
    # This models the brightness enhancement at small phase angles
    # The surge width h_s ≈ 0.06 rad (≈3.4°) is typical for lunar regolith
    # B_S0 is reduced to 0.4 to avoid over-brightening at opposition
    # Opposition effect: B(α) = B_S0 / (1 + tan(α/2) / h_s)
    h_s = 0.06  # Opposition surge angular width parameter (radians)
    B_S0 = 0.4  # Opposition surge amplitude (reduced from typical 1.0)
    if alpha < 0.1:
        # Very small angles - avoid numerical issues
        opposition_factor = B_S0
    else:
        tan_half_alpha = math.tan(alpha_rad / 2.0)
        opposition_factor = B_S0 / (1.0 + tan_half_alpha / h_s)

    # Phase function coefficients from polynomial fit to lunar photometry
    # These coefficients reproduce the observed phase curve within ±0.1 mag
    # for phase angles from 0° to 150°
    #
    # The polynomial: Φ(α) = c0 + c1*α + c2*α²
    # where α is in degrees
    #
    # Reference values for calibration:
    # - Full Moon (α≈0°): V = -12.74
    # - Quarter Moon (α≈90°): V ≈ -10.0 to -10.5
    # - Crescent Moon (α≈135°): V ≈ -6 to -8
    #
    # Coefficients adjusted to match Lane & Irvine (1973) phase curve
    c1 = 0.028  # Linear dimming coefficient (mag/degree)
    c2 = 1.0e-5  # Small quadratic correction for phase curve shape

    # Calculate phase darkening (polynomial phase function)
    # This gives ~2.5 mag at 90°, which brings Quarter Moon to about -10.2
    phase_darkening = c1 * alpha + c2 * alpha**2

    # Opposition surge correction (applied as magnitude reduction at low phase)
    # Convert opposition factor to magnitude: -2.5 * log10(1 + opposition_factor)
    # At α=0: opposition_factor ≈ 0.4, so surge ≈ -0.4 mag
    # At α>20°: opposition_factor ≈ 0, so surge ≈ 0
    if opposition_factor > 0.001:
        opposition_surge_mag = -2.5 * math.log10(1.0 + opposition_factor)
    else:
        opposition_surge_mag = 0.0

    # Combine all terms
    # Note: opposition surge makes Moon brighter (negative), so we add it
    magnitude = V0 + dist_correction + phase_darkening + opposition_surge_mag

    # For very thin crescents (phase angle > 160°), the brightness drops rapidly
    # This is because only a thin sliver is illuminated
    if alpha > 160.0:
        # Additional dimming for thin crescent phases
        extra_dimming = 0.02 * (alpha - 160.0) ** 1.5
        magnitude += extra_dimming

    return magnitude


def swe_pheno_ut(tjd_ut: float, ipl: int, iflag: int) -> Tuple[Tuple[float, ...], int]:
    """
    Compute planetary phenomena for Universal Time.

    Calculates phase angle, illuminated fraction, elongation, apparent diameter,
    and visual magnitude for planets and the Moon.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_SUN, SE_MOON, SE_MERCURY, etc.)
        iflag: Calculation flags (SEFLG_TRUEPOS, SEFLG_HELCTR, etc.)

    Returns:
        Tuple containing:
            - attr: Tuple of at least 5 doubles:
                - attr[0]: Phase angle (Earth-planet-Sun) in degrees
                - attr[1]: Phase (illuminated fraction of disc, 0.0 to 1.0)
                - attr[2]: Elongation of planet from Sun in degrees
                - attr[3]: Apparent diameter of disc in arcseconds
                - attr[4]: Apparent visual magnitude
            - retflag: Return flag value

    Note:
        - For the Sun: phase angle = 0, phase = 1.0, elongation = 0
        - For the Moon: Hapke photometric model with opposition surge correction
        - Phase = 0.0 means new (fully dark), Phase = 1.0 means full (fully illuminated)
        - Elongation is measured from the Sun (0° = conjunction, 180° = opposition)

    Example:
        >>> attr, flag = swe_pheno_ut(2451545.0, SE_MARS, 0)
        >>> print(f"Phase angle: {attr[0]:.2f}°")
        >>> print(f"Illumination: {attr[1]*100:.1f}%")
        >>> print(f"Elongation: {attr[2]:.2f}°")
        >>> print(f"Diameter: {attr[3]:.2f} arcsec")
        >>> print(f"Magnitude: {attr[4]:.2f}")
    """
    ts = get_timescale()
    t = ts.ut1_jd(tjd_ut)
    return _calc_pheno(t, ipl, iflag)


def swe_pheno(tjd_et: float, ipl: int, iflag: int) -> Tuple[Tuple[float, ...], int]:
    """
    Compute planetary phenomena for Ephemeris Time (TT/ET).

    Same as swe_pheno_ut() but takes Terrestrial Time instead of Universal Time.

    Args:
        tjd_et: Julian Day in Ephemeris Time (TT/ET)
        ipl: Planet/body ID (SE_SUN, SE_MOON, SE_MERCURY, etc.)
        iflag: Calculation flags

    Returns:
        Tuple containing:
            - attr: Tuple of phenomenon values (phase_angle, phase, elongation, diameter, magnitude)
            - retflag: Return flag value

    See Also:
        swe_pheno_ut: Same function for Universal Time input
    """
    ts = get_timescale()
    t = ts.tt_jd(tjd_et)
    return _calc_pheno(t, ipl, iflag)


def _calc_pheno(t, ipl: int, iflag: int) -> Tuple[Tuple[float, ...], int]:
    """
    Internal function to compute planetary phenomena.

    Calculates:
    1. Phase angle: angle Sun-Planet-Earth (for inner planets) or Earth-Planet-Sun (for outer)
    2. Phase: illuminated fraction using (1 + cos(phase_angle)) / 2
    3. Elongation: angular separation between planet and Sun as seen from Earth
    4. Apparent diameter: angular size of planet's disc
    5. Visual magnitude: brightness using empirical formulas

    Args:
        t: Skyfield Time object
        ipl: Planet/body ID
        iflag: Calculation flags

    Returns:
        Tuple of (attr_tuple, return_flag)
    """

    planets = get_planets()

    # Initialize return values
    phase_angle = 0.0
    phase = 1.0
    elongation = 0.0
    diameter = 0.0
    magnitude = 0.0

    # Special case: Sun
    if ipl == SE_SUN:
        # Sun is always "full" from Earth's perspective
        # Get Sun distance
        earth = planets["earth"]
        sun = planets["sun"]
        sun_pos = earth.at(t).observe(sun).apparent()
        _, _, sun_dist = sun_pos.radec()

        phase_angle = 0.0
        phase = 1.0
        elongation = 0.0

        # Apparent diameter of Sun based on physical radius
        sun_dist_au = sun_dist.au
        sun_radius_km = _BODY_RADIUS_KM.get(SE_SUN, 695700.0)
        diameter = _calc_apparent_diameter(sun_radius_km, sun_dist_au)

        # Sun magnitude (approximately -26.74 at 1 AU)
        magnitude = (
            -26.74 + 5.0 * math.log10(sun_dist_au) if sun_dist_au > 0 else -26.74
        )

        attr = (phase_angle, phase, elongation, diameter, magnitude) + (0.0,) * 15
        return attr, iflag

    # Get Earth, Sun, and target body positions
    earth = planets["earth"]
    sun = planets["sun"]

    # Get geocentric position of target
    if ipl == SE_MOON:
        target = planets["moon"]
    elif ipl in _PLANET_MAP:
        target_name = _PLANET_MAP[ipl]
        # Try planet center first, fall back to barycenter if not available
        try:
            target = planets[target_name]
        except KeyError:
            if target_name in _PLANET_FALLBACK:
                target = planets[_PLANET_FALLBACK[target_name]]
            else:
                raise
    else:
        # Unsupported body - return zeros
        attr = (0.0,) * 20
        return attr, iflag

    # Observer depends on flags
    if iflag & SEFLG_HELCTR:
        observer = sun
    else:
        observer = earth

    # Get apparent positions
    if iflag & SEFLG_TRUEPOS:
        # Geometric position (no light time)
        target_pos_geo = observer.at(t).observe(target)
        sun_pos_geo = observer.at(t).observe(sun) if ipl != SE_MOON else None
    else:
        # Apparent position
        target_pos_geo = observer.at(t).observe(target).apparent()
        sun_pos_geo = (
            observer.at(t).observe(sun).apparent()
            if not (iflag & SEFLG_HELCTR)
            else None
        )

    # Get heliocentric position of target for phase calculations
    target_helio = sun.at(t).observe(target)
    target_helio_dist = math.sqrt(sum(x**2 for x in target_helio.position.au))

    # Get geocentric distance
    target_geo_dist = math.sqrt(sum(x**2 for x in target_pos_geo.position.au))

    # Special handling for Moon
    if ipl == SE_MOON:
        # For Moon, we need Sun position from Earth
        sun_from_earth = earth.at(t).observe(sun).apparent()

        # Get RA/Dec of Moon and Sun
        moon_ra, moon_dec, moon_dist = target_pos_geo.radec()
        sun_ra, sun_dec, sun_dist = sun_from_earth.radec()

        # Elongation: angular distance between Moon and Sun
        # Using spherical trigonometry
        moon_ra_rad = moon_ra.radians
        moon_dec_rad = moon_dec.radians
        sun_ra_rad = sun_ra.radians
        sun_dec_rad = sun_dec.radians

        cos_elong = math.sin(moon_dec_rad) * math.sin(sun_dec_rad) + math.cos(
            moon_dec_rad
        ) * math.cos(sun_dec_rad) * math.cos(moon_ra_rad - sun_ra_rad)
        cos_elong = max(-1.0, min(1.0, cos_elong))
        elongation = math.degrees(math.acos(cos_elong))

        # Phase angle for Moon
        # Using the formula from Meeus "Astronomical Algorithms"
        # Phase angle i is approximately: cos(i) = -cos(elongation)
        # More precisely, use triangle Sun-Moon-Earth
        r_sun = sun_dist.au  # Earth-Sun distance
        r_moon = moon_dist.au  # Earth-Moon distance

        # Get Moon's heliocentric distance
        moon_helio = sun.at(t).observe(planets["moon"])
        R_moon = math.sqrt(
            sum(x**2 for x in moon_helio.position.au)
        )  # Sun-Moon distance

        # Use law of cosines to get phase angle
        # In triangle Sun-Moon-Earth: i is at Moon vertex
        # cos(i) = (R^2 + r_moon^2 - r_sun^2) / (2 * R * r_moon)
        if R_moon > 0 and r_moon > 0:
            cos_phase = (R_moon**2 + r_moon**2 - r_sun**2) / (2 * R_moon * r_moon)
            cos_phase = max(-1.0, min(1.0, cos_phase))
            phase_angle = math.degrees(math.acos(cos_phase))
        else:
            phase_angle = 180.0 - elongation

        # Phase (illuminated fraction)
        phase = (1.0 + math.cos(math.radians(phase_angle))) / 2.0

        # Moon's apparent diameter based on physical radius
        moon_radius_km = _BODY_RADIUS_KM.get(SE_MOON, 1737.4)
        diameter = _calc_apparent_diameter(moon_radius_km, r_moon)

        # Moon's magnitude using Hapke photometric model
        # This provides ±0.1 mag accuracy across all phases, compared to
        # the simplified formula which can have errors up to 0.5 mag
        magnitude = _calc_moon_magnitude_hapke(phase_angle, r_moon)

        attr = (phase_angle, phase, elongation, diameter, magnitude) + (0.0,) * 15
        return attr, iflag

    # For planets: calculate elongation, phase angle, etc.
    if sun_pos_geo is not None:
        # Get RA/Dec of planet and Sun
        planet_ra, planet_dec, planet_dist = target_pos_geo.radec()
        sun_ra, sun_dec, sun_dist = sun_pos_geo.radec()

        # Elongation: angular distance between planet and Sun
        planet_ra_rad = planet_ra.radians
        planet_dec_rad = planet_dec.radians
        sun_ra_rad = sun_ra.radians
        sun_dec_rad = sun_dec.radians

        cos_elong = math.sin(planet_dec_rad) * math.sin(sun_dec_rad) + math.cos(
            planet_dec_rad
        ) * math.cos(sun_dec_rad) * math.cos(planet_ra_rad - sun_ra_rad)
        cos_elong = max(-1.0, min(1.0, cos_elong))
        elongation = math.degrees(math.acos(cos_elong))

        # Phase angle calculation using triangle Sun-Planet-Earth
        # r = geocentric distance of planet
        # R = heliocentric distance of planet
        # d_sun = geocentric distance of Sun (≈ 1 AU)
        r = target_geo_dist
        R = target_helio_dist
        d_sun = sun_dist.au

        # Law of cosines: phase angle i is at the planet vertex
        # cos(i) = (R^2 + r^2 - d_sun^2) / (2 * R * r)
        if R > 0 and r > 0:
            cos_phase = (R**2 + r**2 - d_sun**2) / (2 * R * r)
            cos_phase = max(-1.0, min(1.0, cos_phase))
            phase_angle = math.degrees(math.acos(cos_phase))
        else:
            phase_angle = 0.0
    else:
        # Heliocentric case - no elongation from Sun
        elongation = 0.0
        phase_angle = 0.0

    # Phase (illuminated fraction)
    phase = (1.0 + math.cos(math.radians(phase_angle))) / 2.0

    # Apparent diameter based on physical radius
    body_radius_km = _BODY_RADIUS_KM.get(
        ipl, 1000.0
    )  # Default 1000 km for unknown bodies
    diameter = _calc_apparent_diameter(body_radius_km, target_geo_dist)

    # Visual magnitude
    # For Saturn, we need ecliptic coordinates for ring tilt calculation
    geo_lon = 0.0
    geo_lat = 0.0
    helio_lon = 0.0
    helio_lat = 0.0
    tjd = 0.0

    if ipl == SE_SATURN:
        # Get Julian day from Skyfield time object
        tjd = t.tt

        # Get geocentric ecliptic coordinates
        try:
            geo_ecl_lat, geo_ecl_lon, _ = target_pos_geo.frame_latlon(ecliptic_frame)
            geo_lon = geo_ecl_lon.degrees
            geo_lat = geo_ecl_lat.degrees
        except Exception:
            geo_lon = 0.0
            geo_lat = 0.0

        # Get heliocentric ecliptic coordinates
        try:
            helio_ecl_lat, helio_ecl_lon, _ = target_helio.frame_latlon(ecliptic_frame)
            helio_lon = helio_ecl_lon.degrees
            helio_lat = helio_ecl_lat.degrees
        except Exception:
            helio_lon = 0.0
            helio_lat = 0.0

    magnitude = _calc_planet_magnitude(
        ipl,
        target_helio_dist,
        target_geo_dist,
        phase_angle,
        geo_lon,
        geo_lat,
        helio_lon,
        helio_lat,
        tjd,
    )

    # Return tuple with at least 20 elements (pyswisseph API compatibility)
    attr = (phase_angle, phase, elongation, diameter, magnitude) + (0.0,) * 15
    return attr, iflag


def _calc_planet_magnitude(
    ipl: int,
    helio_dist: float,
    geo_dist: float,
    phase_angle: float,
    geo_lon: float = 0.0,
    geo_lat: float = 0.0,
    helio_lon: float = 0.0,
    helio_lat: float = 0.0,
    tjd: float = 0.0,
) -> float:
    """
    Calculate visual magnitude of a planet.

    Uses Mallama 2018 formulas for Mercury, Venus, Mars, Jupiter, Saturn
    for pyswisseph API compatibility. These formulas are from:
    A. Mallama, J. Hilton, "Computing Apparent Planetary Magnitudes for
    The Astronomical Almanac" (2018).

    Args:
        ipl: Planet ID
        helio_dist: Heliocentric distance in AU
        geo_dist: Geocentric distance in AU
        phase_angle: Phase angle in degrees
        geo_lon: Geocentric ecliptic longitude in degrees (for Saturn)
        geo_lat: Geocentric ecliptic latitude in degrees (for Saturn)
        helio_lon: Heliocentric ecliptic longitude in degrees (for Saturn)
        helio_lat: Heliocentric ecliptic latitude in degrees (for Saturn)
        tjd: Julian day (for Saturn ring tilt calculation)

    Returns:
        Visual magnitude (smaller = brighter)
    """
    # Distance factor: 5 * log10(r * d)
    if helio_dist > 0 and geo_dist > 0:
        dist_factor = 5.0 * math.log10(helio_dist * geo_dist)
    else:
        dist_factor = 0.0

    a = phase_angle  # Phase angle in degrees

    # Mercury - Mallama 2018, 6th order polynomial
    if ipl == SE_MERCURY:
        a2 = a * a
        a3 = a2 * a
        a4 = a3 * a
        a5 = a4 * a
        a6 = a5 * a
        magnitude = (
            -0.613
            + a * 6.3280e-02
            - a2 * 1.6336e-03
            + a3 * 3.3644e-05
            - a4 * 3.4265e-07
            + a5 * 1.6893e-09
            - a6 * 3.0334e-12
        )
        magnitude += dist_factor
        return magnitude

    # Venus - Mallama 2018, piecewise polynomial
    if ipl == SE_VENUS:
        a2 = a * a
        a3 = a2 * a
        a4 = a3 * a
        if a <= 163.7:
            magnitude = (
                -4.384
                - a * 1.044e-03
                + a2 * 3.687e-04
                - a3 * 2.814e-06
                + a4 * 8.938e-09
            )
        else:
            magnitude = 236.05828 - a * 2.81914e00 + a2 * 8.39034e-03
        magnitude += dist_factor
        return magnitude

    # Mars - Mallama 2018, piecewise polynomial
    if ipl == SE_MARS:
        a2 = a * a
        if a <= 50.0:
            magnitude = -1.601 + a * 0.02267 - a2 * 0.0001302
        else:
            magnitude = -0.367 - a * 0.02573 + a2 * 0.0003445
        magnitude += dist_factor
        return magnitude

    # Jupiter - Mallama 2018
    if ipl == SE_JUPITER:
        a2 = a * a
        magnitude = -9.395 - a * 3.7e-04 + a2 * 6.16e-04
        magnitude += dist_factor
        return magnitude

    # Saturn - Mallama 2018 with ring tilt from Meeus
    if ipl == SE_SATURN:
        # Ring tilt calculation from Meeus, p. 301ff
        # T is centuries from J2000
        T = (tjd - _J2000) / 36525.0

        # Ring plane inclination and ascending node
        incl = math.radians(28.075216 - 0.012998 * T + 0.000004 * T * T)
        omega = math.radians(169.508470 + 1.394681 * T + 0.000412 * T * T)

        # sinB is the sine of the ring tilt angle as seen from Earth/Sun
        # B is the "mean tilt of the ring plane to the Earth and Sun"
        # From Meeus formulae for ring visibility

        # Geocentric position contribution
        sin_B = math.sin(incl) * math.cos(math.radians(geo_lat)) * math.sin(
            math.radians(geo_lon) - omega
        ) - math.cos(incl) * math.sin(math.radians(geo_lat))

        # Heliocentric position contribution
        sin_B2 = math.sin(incl) * math.cos(math.radians(helio_lat)) * math.sin(
            math.radians(helio_lon) - omega
        ) - math.cos(incl) * math.sin(math.radians(helio_lat))

        # Mean of the two tilt angles
        sin_B = abs(math.sin((math.asin(sin_B) + math.asin(sin_B2)) / 2.0))

        # Mallama 2018 formula for Saturn with ring contribution
        magnitude = (
            -8.914 - 1.825 * sin_B + 0.026 * a - 0.378 * sin_B * math.exp(-2.25 * a)
        )
        magnitude += dist_factor
        return magnitude

    # Pluto - Mallama & Hilton 2018 formula
    # From "Computing Apparent Planetary Magnitudes for The Astronomical Almanac"
    # V(1,0) = -1.024 ± 0.003 mag (absolute magnitude at r=d=1 AU, α=0°)
    # Phase coefficient β = 0.0362 ± 0.0004 mag/degree
    # Formula: V = V(1,0) + 5*log10(r*d) + β*α
    # Note: Pluto also has a rotational light curve amplitude of ~±0.15 mag
    # with period 6.3872 days, but this requires sub-observer longitude data.
    if ipl == SE_PLUTO:
        V0 = -1.024  # Absolute magnitude V(1,0)
        beta = 0.0362  # Phase coefficient in mag/degree
        phase_correction = beta * a  # Linear phase correction
        magnitude = V0 + dist_factor + phase_correction
        return magnitude

    # Outer planets using simplified formula
    if ipl in _PLANET_MAG_PARAMS:
        V0, B1, B2, B3 = _PLANET_MAG_PARAMS[ipl]
        phase_factor = B1 * a + B2 * a**2 + B3 * a**3
        magnitude = V0 + dist_factor + phase_factor
        return magnitude

    # Unknown planet - return approximate magnitude
    H = 10.0  # Assumed absolute magnitude
    if helio_dist > 0 and geo_dist > 0:
        return H + 5.0 * math.log10(helio_dist * geo_dist)
    return H


# Aliases for reference API compatibility
pheno_ut = swe_pheno_ut
pheno = swe_pheno


# =============================================================================
# Elongation Helper Functions
# =============================================================================


def get_elongation_from_sun(
    tjd_ut: float, ipl: int, iflag: int = 0
) -> Tuple[float, bool]:
    """
    Calculate the elongation of a planet from the Sun with morning/evening star distinction.

    This function returns the elongation (angular separation from the Sun) and
    determines whether the planet is a morning star (western elongation) or
    evening star (eastern elongation).

    Elongation convention:
        - Eastern elongation (positive): Planet is east of Sun, visible after sunset
          (evening star)
        - Western elongation (negative): Planet is west of Sun, visible before sunrise
          (morning star)

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_MERCURY, SE_VENUS, SE_MARS, etc.)
        iflag: Calculation flags (default: 0)

    Returns:
        Tuple containing:
            - elongation: Signed elongation in degrees
                - Positive = eastern elongation (evening star)
                - Negative = western elongation (morning star)
            - is_evening_star: True if planet is an evening star (eastern elongation),
                               False if morning star (western elongation)

    Note:
        This function is most useful for Mercury and Venus (inferior planets),
        which alternate between morning and evening star status. Superior planets
        (Mars, Jupiter, Saturn, etc.) can also be classified this way, though
        they are visible for longer periods.

    Example:
        >>> from libephemeris import get_elongation_from_sun, SE_VENUS
        >>> elongation, is_evening = get_elongation_from_sun(2451545.0, SE_VENUS)
        >>> if is_evening:
        ...     print(f"Venus is an evening star at {abs(elongation):.1f}° eastern elongation")
        ... else:
        ...     print(f"Venus is a morning star at {abs(elongation):.1f}° western elongation")
    """
    # Get planet and Sun positions
    planet_pos, _ = swe_calc_ut(tjd_ut, ipl, iflag)
    sun_pos, _ = swe_calc_ut(tjd_ut, SE_SUN, iflag)

    planet_lon = float(planet_pos[0])
    sun_lon = float(sun_pos[0])

    # Calculate the longitude difference (planet - Sun)
    # Normalize to -180 to +180 range
    lon_diff = planet_lon - sun_lon
    if lon_diff > 180.0:
        lon_diff -= 360.0
    elif lon_diff < -180.0:
        lon_diff += 360.0

    # Positive difference = planet is east of Sun = evening star
    # Negative difference = planet is west of Sun = morning star
    is_evening_star = bool(lon_diff > 0)

    return lon_diff, is_evening_star


def get_signed_elongation(tjd_ut: float, ipl: int, iflag: int = 0) -> float:
    """
    Calculate signed elongation of a planet from the Sun.

    Returns a signed value where:
        - Positive values indicate eastern elongation (evening star)
        - Negative values indicate western elongation (morning star)

    This is equivalent to calling get_elongation_from_sun() and returning
    only the first element.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_MERCURY, SE_VENUS, SE_MARS, etc.)
        iflag: Calculation flags (default: 0)

    Returns:
        Signed elongation in degrees:
            - Positive = eastern elongation (evening star)
            - Negative = western elongation (morning star)

    Example:
        >>> from libephemeris import get_signed_elongation, SE_MERCURY
        >>> elong = get_signed_elongation(2451545.0, SE_MERCURY)
        >>> print(f"Mercury elongation: {elong:+.1f}°")
    """
    elongation, _ = get_elongation_from_sun(tjd_ut, ipl, iflag)
    return elongation


def is_morning_star(tjd_ut: float, ipl: int, iflag: int = 0) -> bool:
    """
    Determine if a planet is a morning star (western elongation).

    A planet is a morning star when it is west of the Sun (negative elongation),
    meaning it rises before the Sun and is visible in the eastern sky before sunrise.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_MERCURY, SE_VENUS, SE_MARS, etc.)
        iflag: Calculation flags (default: 0)

    Returns:
        True if the planet is a morning star (western elongation),
        False if it is an evening star (eastern elongation)

    Note:
        For the Sun, Moon, or unsupported bodies, this returns False.

    Example:
        >>> from libephemeris import is_morning_star, SE_VENUS
        >>> if is_morning_star(2451545.0, SE_VENUS):
        ...     print("Venus is a morning star")
        ... else:
        ...     print("Venus is an evening star")
    """
    if ipl == SE_SUN:
        return False  # Sun cannot be morning/evening star
    _, is_evening = get_elongation_from_sun(tjd_ut, ipl, iflag)
    return not is_evening


def is_evening_star(tjd_ut: float, ipl: int, iflag: int = 0) -> bool:
    """
    Determine if a planet is an evening star (eastern elongation).

    A planet is an evening star when it is east of the Sun (positive elongation),
    meaning it sets after the Sun and is visible in the western sky after sunset.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_MERCURY, SE_VENUS, SE_MARS, etc.)
        iflag: Calculation flags (default: 0)

    Returns:
        True if the planet is an evening star (eastern elongation),
        False if it is a morning star (western elongation)

    Note:
        For the Sun, Moon, or unsupported bodies, this returns False.

    Example:
        >>> from libephemeris import is_evening_star, SE_VENUS
        >>> if is_evening_star(2451545.0, SE_VENUS):
        ...     print("Venus is an evening star")
        ... else:
        ...     print("Venus is a morning star")
    """
    if ipl == SE_SUN:
        return False  # Sun cannot be morning/evening star
    _, is_evening = get_elongation_from_sun(tjd_ut, ipl, iflag)
    return is_evening


def get_elongation_type(tjd_ut: float, ipl: int, iflag: int = 0) -> str:
    """
    Get the elongation type as a descriptive string.

    Args:
        tjd_ut: Julian Day in Universal Time (UT1)
        ipl: Planet/body ID (SE_MERCURY, SE_VENUS, SE_MARS, etc.)
        iflag: Calculation flags (default: 0)

    Returns:
        One of: "eastern" (evening star), "western" (morning star), or "none" (Sun)

    Example:
        >>> from libephemeris import get_elongation_type, SE_VENUS
        >>> elong_type = get_elongation_type(2451545.0, SE_VENUS)
        >>> print(f"Venus has {elong_type} elongation")
    """
    if ipl == SE_SUN:
        return "none"
    _, is_evening = get_elongation_from_sun(tjd_ut, ipl, iflag)
    return "eastern" if is_evening else "western"
