"""Satellite tracking — CelesTrak/TLE fetch, SGP4 propagation, intel classification.

CelesTrak Fair Use Policy (https://celestrak.org/NORAD/elements/):
  - Do NOT request the same data more than once every 24 hours
  - Use If-Modified-Since headers for conditional requests
  - No parallel/concurrent connections — one request at a time
  - Set a descriptive User-Agent

Analysis features (derived from cached TLEs — no extra network requests):
  - Maneuver detection: TLE-to-TLE comparison per satellite
  - Decay anomaly: mean-motion change rate monitoring
  - Overflight counting: 24h ground-track sampling for a bounding box
"""

import math
import time
import json
import os
import re
import logging
import requests
from pathlib import Path
from datetime import datetime, timedelta
from sgp4.api import Satrec, WGS72, jday
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh

logger = logging.getLogger("services.data_fetcher")


def _gmst(jd_ut1):
    """Greenwich Mean Sidereal Time in radians from Julian Date."""
    t = (jd_ut1 - 2451545.0) / 36525.0
    gmst_sec = (
        67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t
    )
    gmst_rad = (gmst_sec % 86400) / 86400.0 * 2 * math.pi
    return gmst_rad


# Satellite GP data cache
# CelesTrak fair use: fetch at most once per 24 hours (86400s).
# SGP4 propagation runs every 60s using cached TLEs — positions stay live.
_CELESTRAK_FETCH_INTERVAL = 86400  # 24 hours
_MIN_VISIBLE_SATELLITE_CATALOG = int(os.environ.get("SHADOWBROKER_MIN_VISIBLE_SATELLITES", "350"))
_MAX_VISIBLE_SATELLITE_CATALOG = int(os.environ.get("SHADOWBROKER_MAX_VISIBLE_SATELLITES", "450"))
_CELESTRAK_VISIBLE_GROUPS = {
    "military": {"mission": "military", "sat_type": "Military / Defense"},
    "radar": {"mission": "sar", "sat_type": "Radar / SAR"},
    "resource": {"mission": "earth_observation", "sat_type": "Earth Observation"},
    "weather": {"mission": "weather", "sat_type": "Weather / Meteorology"},
    "gnss": {"mission": "navigation", "sat_type": "GNSS / Navigation"},
    "science": {"mission": "science", "sat_type": "Science"},
}
_TLE_VISIBLE_FALLBACK_TERMS = {
    "COSMOS": {"mission": "military", "sat_type": "Russian / Soviet Military"},
    "USA": {"mission": "military", "sat_type": "US Military / NRO"},
    "NROL": {"mission": "military", "sat_type": "Classified NRO"},
    "GPS": {"mission": "navigation", "sat_type": "GPS Navigation"},
    "GALILEO": {"mission": "navigation", "sat_type": "Galileo Navigation"},
    "BEIDOU": {"mission": "navigation", "sat_type": "BeiDou Navigation"},
    "GLONASS": {"mission": "navigation", "sat_type": "GLONASS Navigation"},
    "NOAA": {"mission": "weather", "sat_type": "NOAA Weather"},
    "METEOR": {"mission": "weather", "sat_type": "Meteor Weather"},
    "SENTINEL": {"mission": "earth_observation", "sat_type": "Sentinel Earth Observation"},
    "LANDSAT": {"mission": "earth_observation", "sat_type": "Landsat Earth Observation"},
    "WORLDVIEW": {"mission": "commercial_imaging", "sat_type": "Maxar High-Res"},
    "PLEIADES": {"mission": "commercial_imaging", "sat_type": "Airbus Imaging"},
    "SKYSAT": {"mission": "commercial_imaging", "sat_type": "Planet Video"},
    "JILIN": {"mission": "commercial_imaging", "sat_type": "Jilin Imaging"},
    "FLOCK": {"mission": "commercial_imaging", "sat_type": "PlanetScope"},
    "LEMUR": {"mission": "commercial_rf", "sat_type": "Spire RF / AIS"},
    "ICEYE": {"mission": "sar", "sat_type": "ICEYE SAR"},
    "UMBRA": {"mission": "sar", "sat_type": "Umbra SAR"},
    "CAPELLA": {"mission": "sar", "sat_type": "Capella SAR"},
}
_sat_gp_cache = {"data": None, "last_fetch": 0, "source": "none", "last_modified": None}
_sat_classified_cache = {"data": None, "gp_fetch_ts": 0}
_SAT_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache.json"
_SAT_CACHE_META_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache_meta.json"

# ── Historical TLE storage for maneuver & decay detection ───────────────────
# Stores the previous TLE snapshot keyed by NORAD_CAT_ID.
# Populated when a fresh CelesTrak fetch replaces cached data.
# Persisted to disk so analysis survives restarts.
_SAT_HISTORY_PATH = Path(__file__).parent.parent.parent / "data" / "sat_tle_history.json"
_tle_history: dict[int, dict] = {}  # {norad_id: {elements + "epoch_ts"}}


def _load_tle_history():
    """Load previous TLE snapshot from disk."""
    global _tle_history
    try:
        if _SAT_HISTORY_PATH.exists():
            with open(_SAT_HISTORY_PATH, "r") as f:
                raw = json.load(f)
            _tle_history = {int(k): v for k, v in raw.items()}
            logger.info(f"Satellites: Loaded TLE history for {len(_tle_history)} objects")
    except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError) as e:
        logger.warning(f"Satellites: Failed to load TLE history: {e}")
        _tle_history = {}


def _save_tle_history():
    """Persist current TLE snapshot as history for next comparison."""
    try:
        _SAT_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
        with open(_SAT_HISTORY_PATH, "w") as f:
            json.dump(_tle_history, f)
    except (IOError, OSError) as e:
        logger.warning(f"Satellites: Failed to save TLE history: {e}")


def _snapshot_current_tles(gp_data):
    """Capture orbital elements from current GP data as the new 'previous' snapshot.

    Called once per CelesTrak fetch (every 24h). The old snapshot becomes
    the comparison baseline for maneuver/decay detection.
    """
    global _tle_history
    new_snapshot = {}
    for sat in gp_data:
        norad_id = sat.get("NORAD_CAT_ID")
        if norad_id is None:
            continue
        epoch_str = sat.get("EPOCH", "")
        try:
            epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
            epoch_ts = epoch_dt.timestamp()
        except (ValueError, TypeError):
            epoch_ts = 0
        new_snapshot[int(norad_id)] = {
            "MEAN_MOTION": sat.get("MEAN_MOTION"),
            "ECCENTRICITY": sat.get("ECCENTRICITY"),
            "INCLINATION": sat.get("INCLINATION"),
            "RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"),
            "BSTAR": sat.get("BSTAR"),
            "epoch_ts": epoch_ts,
        }
    _tle_history = new_snapshot
    _save_tle_history()


def _load_sat_cache():
    """Load satellite GP data from local disk cache."""
    try:
        if _SAT_CACHE_PATH.exists():
            import os

            age_hours = (time.time() - os.path.getmtime(str(_SAT_CACHE_PATH))) / 3600
            if age_hours < 48:
                with open(_SAT_CACHE_PATH, "r") as f:
                    data = json.load(f)
                if isinstance(data, list) and len(data) > 10:
                    logger.info(
                        f"Satellites: Loaded {len(data)} records from disk cache ({age_hours:.1f}h old)"
                    )
                    # Restore last_modified from metadata
                    _load_cache_meta()
                    return data
            else:
                logger.info(f"Satellites: Disk cache is {age_hours:.0f}h old, will try fresh fetch")
    except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError) as e:
        logger.warning(f"Satellites: Failed to load disk cache: {e}")
    return None


def _save_sat_cache(data):
    """Save satellite GP data to local disk cache."""
    try:
        _SAT_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
        with open(_SAT_CACHE_PATH, "w") as f:
            json.dump(data, f)
        _save_cache_meta()
        logger.info(f"Satellites: Saved {len(data)} records to disk cache")
    except (IOError, OSError) as e:
        logger.warning(f"Satellites: Failed to save disk cache: {e}")


def _load_cache_meta():
    """Load cache metadata (Last-Modified timestamp) from disk."""
    try:
        if _SAT_CACHE_META_PATH.exists():
            with open(_SAT_CACHE_META_PATH, "r") as f:
                meta = json.load(f)
            _sat_gp_cache["last_modified"] = meta.get("last_modified")
    except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError):
        pass


def _save_cache_meta():
    """Save cache metadata to disk."""
    try:
        with open(_SAT_CACHE_META_PATH, "w") as f:
            json.dump({"last_modified": _sat_gp_cache.get("last_modified")}, f)
    except (IOError, OSError):
        pass


# Satellite intelligence classification database
# Matched by substring against OBJECT_NAME (case-insensitive).
# Order matters — first match wins, so specific names go before generic prefixes.
_SAT_INTEL_DB = [
    # ── USA Keyhole / Reconnaissance ────────────────────────────────────────
    ("USA 224", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
    ("USA 245", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
    ("USA 290", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
    ("USA 314", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
    ("USA 338", {"country": "USA", "mission": "military_recon", "sat_type": "Keyhole Successor", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
    # ── USA SIGINT / NRO ────────────────────────────────────────────────────
    ("NROL", {"country": "USA", "mission": "sigint", "sat_type": "Classified NRO", "wiki": "https://en.wikipedia.org/wiki/National_Reconnaissance_Office"}),
    ("MENTOR", {"country": "USA", "mission": "sigint", "sat_type": "SIGINT / ELINT (Orion)", "wiki": "https://en.wikipedia.org/wiki/Mentor_(satellite)"}),
    ("TRUMPET", {"country": "USA", "mission": "sigint", "sat_type": "SIGINT (HEO)", "wiki": "https://en.wikipedia.org/wiki/Trumpet_(satellite)"}),
    ("INTRUDER", {"country": "USA", "mission": "sigint", "sat_type": "Naval SIGINT (NOSS)", "wiki": "https://en.wikipedia.org/wiki/Naval_Ocean_Surveillance_System"}),
    # ── USA Early Warning / Missile Defense ─────────────────────────────────
    ("SBIRS", {"country": "USA", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Space-Based_Infrared_System"}),
    ("DSP", {"country": "USA", "mission": "early_warning", "sat_type": "Defense Support Program", "wiki": "https://en.wikipedia.org/wiki/Defense_Support_Program"}),
    # ── USA Communications (Military) ───────────────────────────────────────
    ("MUOS", {"country": "USA", "mission": "military_comms", "sat_type": "Mobile User Objective System", "wiki": "https://en.wikipedia.org/wiki/Mobile_User_Objective_System"}),
    ("AEHF", {"country": "USA", "mission": "military_comms", "sat_type": "Advanced EHF", "wiki": "https://en.wikipedia.org/wiki/Advanced_Extremely_High_Frequency"}),
    ("WGS", {"country": "USA", "mission": "military_comms", "sat_type": "Wideband Global SATCOM", "wiki": "https://en.wikipedia.org/wiki/Wideband_Global_SATCOM"}),
    ("MILSTAR", {"country": "USA", "mission": "military_comms", "sat_type": "Milstar Secure Comms", "wiki": "https://en.wikipedia.org/wiki/Milstar"}),
    # ── USA Navigation ──────────────────────────────────────────────────────
    ("NAVSTAR", {"country": "USA", "mission": "navigation", "sat_type": "GPS", "wiki": "https://en.wikipedia.org/wiki/GPS_satellite_blocks"}),
    # ── Russia Reconnaissance ───────────────────────────────────────────────
    ("TOPAZ", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}),
    ("PERSONA", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}),
    ("KONDOR", {"country": "Russia", "mission": "military_sar", "sat_type": "SAR Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Kondor_(satellite)"}),
    ("BARS-M", {"country": "Russia", "mission": "military_recon", "sat_type": "Mapping Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Bars-M"}),
    ("RAZDAN", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Razdan_(satellite)"}),
    ("LOTOS", {"country": "Russia", "mission": "sigint", "sat_type": "ELINT (Lotos-S)", "wiki": "https://en.wikipedia.org/wiki/Lotos-S"}),
    ("PION", {"country": "Russia", "mission": "sigint", "sat_type": "Naval SIGINT/Radar", "wiki": "https://en.wikipedia.org/wiki/Pion-NKS"}),
    ("LUCH", {"country": "Russia", "mission": "sigint", "sat_type": "Relay / SIGINT", "wiki": "https://en.wikipedia.org/wiki/Luch_(satellite)"}),
    # ── Russia Early Warning & Navigation ───────────────────────────────────
    ("TUNDRA", {"country": "Russia", "mission": "early_warning", "sat_type": "Missile Warning (EKS)", "wiki": "https://en.wikipedia.org/wiki/Tundra_(satellite)"}),
    ("GLONASS", {"country": "Russia", "mission": "navigation", "sat_type": "GLONASS", "wiki": "https://en.wikipedia.org/wiki/GLONASS"}),
    # ── China Military / Intel ──────────────────────────────────────────────
    ("YAOGAN", {"country": "China", "mission": "military_recon", "sat_type": "Remote Sensing / ELINT", "wiki": "https://en.wikipedia.org/wiki/Yaogan"}),
    ("GAOFEN", {"country": "China", "mission": "military_recon", "sat_type": "High-Res Imaging", "wiki": "https://en.wikipedia.org/wiki/Gaofen"}),
    ("JILIN", {"country": "China", "mission": "commercial_imaging", "sat_type": "Video / Imaging", "wiki": "https://en.wikipedia.org/wiki/Jilin-1"}),
    ("SHIJIAN", {"country": "China", "mission": "sigint", "sat_type": "ELINT / Tech Demo", "wiki": "https://en.wikipedia.org/wiki/Shijian"}),
    ("TONGXIN JISHU SHIYAN", {"country": "China", "mission": "military_comms", "sat_type": "Military Comms Test", "wiki": "https://en.wikipedia.org/wiki/Tongxin_Jishu_Shiyan"}),
    ("BEIDOU", {"country": "China", "mission": "navigation", "sat_type": "BeiDou", "wiki": "https://en.wikipedia.org/wiki/BeiDou"}),
    ("TIANGONG", {"country": "China", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/Tiangong_space_station"}),
    # ── Allied Military / Intel ─────────────────────────────────────────────
    ("OFEK", {"country": "Israel", "mission": "military_recon", "sat_type": "Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Ofeq"}),
    ("EROS", {"country": "Israel", "mission": "commercial_imaging", "sat_type": "High-Res Imaging", "wiki": "https://en.wikipedia.org/wiki/EROS_(satellite)"}),
    ("CSO", {"country": "France", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/CSO_(satellite)"}),
    ("HELIOS", {"country": "France", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Helios_(satellite)"}),
    ("CERES", {"country": "France", "mission": "sigint", "sat_type": "ELINT Constellation", "wiki": "https://en.wikipedia.org/wiki/CERES_(satellite)"}),
    ("IGS", {"country": "Japan", "mission": "military_recon", "sat_type": "Intelligence Gathering", "wiki": "https://en.wikipedia.org/wiki/Information_Gathering_Satellite"}),
    ("KOMPSAT", {"country": "South Korea", "mission": "military_recon", "sat_type": "Multi-Purpose Satellite", "wiki": "https://en.wikipedia.org/wiki/KOMPSAT"}),
    ("SAR-LUPE", {"country": "Germany", "mission": "military_sar", "sat_type": "SAR Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/SAR-Lupe"}),
    ("SARAH", {"country": "Germany", "mission": "military_sar", "sat_type": "SAR Successor (SARah)", "wiki": "https://en.wikipedia.org/wiki/SARah"}),
    # ── Commercial SAR ──────────────────────────────────────────────────────
    ("CAPELLA", {"country": "USA", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/Capella_Space"}),
    ("ICEYE", {"country": "Finland", "mission": "sar", "sat_type": "SAR Microsatellite", "wiki": "https://en.wikipedia.org/wiki/ICEYE"}),
    ("COSMO-SKYMED", {"country": "Italy", "mission": "sar", "sat_type": "SAR Constellation", "wiki": "https://en.wikipedia.org/wiki/COSMO-SkyMed"}),
    ("TANDEM", {"country": "Germany", "mission": "sar", "sat_type": "SAR Interferometry", "wiki": "https://en.wikipedia.org/wiki/TanDEM-X"}),
    ("PAZ", {"country": "Spain", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/PAZ_(satellite)"}),
    ("UMBRA", {"country": "USA", "mission": "sar", "sat_type": "SAR Microsatellite", "wiki": "https://en.wikipedia.org/wiki/Umbra_(company)"}),
    # ── Commercial Optical Imaging ──────────────────────────────────────────
    ("WORLDVIEW", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar High-Res", "wiki": "https://en.wikipedia.org/wiki/WorldView-3"}),
    ("GEOEYE", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar Imaging", "wiki": "https://en.wikipedia.org/wiki/GeoEye-1"}),
    ("LEGION", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar Legion", "wiki": "https://en.wikipedia.org/wiki/WorldView_Legion"}),
    ("PLEIADES", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Imaging", "wiki": "https://en.wikipedia.org/wiki/Pl%C3%A9iades_(satellite)"}),
    ("SPOT", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Medium-Res", "wiki": "https://en.wikipedia.org/wiki/SPOT_(satellite)"}),
    ("SKYSAT", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Planet Video", "wiki": "https://en.wikipedia.org/wiki/SkySat"}),
    ("BLACKSKY", {"country": "USA", "mission": "commercial_imaging", "sat_type": "BlackSky Imaging", "wiki": "https://en.wikipedia.org/wiki/BlackSky"}),
    # ── Starlink (separate category) ────────────────────────────────────────
    ("STARLINK", {"country": "USA", "mission": "starlink", "sat_type": "Starlink Mega-Constellation", "wiki": "https://en.wikipedia.org/wiki/Starlink"}),
    # ── Other Constellations ────────────────────────────────────────────────
    ("ONEWEB", {"country": "UK", "mission": "constellation", "sat_type": "OneWeb LEO Broadband", "wiki": "https://en.wikipedia.org/wiki/OneWeb"}),
    ("GALILEO", {"country": "EU", "mission": "navigation", "sat_type": "Galileo", "wiki": "https://en.wikipedia.org/wiki/Galileo_(satellite_navigation)"}),
    # ── Space Stations ──────────────────────────────────────────────────────
    ("ISS", {"country": "Intl", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/International_Space_Station"}),
    # ── Generic fallback patterns (last resort) ─────────────────────────────
    ("PLANET", {"country": "USA", "mission": "commercial_imaging", "sat_type": "PlanetScope", "wiki": "https://en.wikipedia.org/wiki/Planet_Labs"}),
]

# CelesTrak SATCAT owner codes → country mapping for satellites not matched by name.
# Used as a secondary classifier alongside name-pattern matching.
_OWNER_CODE_MAP = {
    "US": "USA", "CIS": "Russia", "PRC": "China", "ISS": "Intl",
    "FR": "France", "UK": "UK", "GER": "Germany", "JPN": "Japan",
    "IND": "India", "ISRA": "Israel", "IT": "Italy", "KOR": "South Korea",
    "ESA": "EU", "NATO": "NATO", "TURK": "Turkey", "UAE": "UAE",
    "AUS": "Australia", "CA": "Canada", "SPN": "Spain", "FIN": "Finland",
    "BRAZ": "Brazil", "IRAN": "Iran", "NKOR": "North Korea",
}

# ── Maneuver detection thresholds (per Lemmens & Krag 2014, Kim et al. 2021) ─
# These are above TLE fitting noise but low enough to catch real maneuvers.
_MANEUVER_THRESHOLDS = {
    "period_min": 0.1,        # minutes — above TLE noise (~0.01–0.05 min)
    "inclination_deg": 0.05,  # degrees — above J2 secular drift (~0.001°/day)
    "eccentricity": 0.005,    # above TLE fitting noise (~0.0001–0.001)
    "raan_residual_deg": 0.5, # degrees — only after J2 correction (Vallado §9.4)
}

# ── Decay anomaly threshold ─────────────────────────────────────────────────
# Flag if mean motion change rate exceeds this (rev/day per day).
# Normal drag-induced decay is ~0.001 rev/day/day for LEO.
_DECAY_MM_RATE_THRESHOLD = 0.01  # rev/day per day


def _j2_raan_rate(inclination_deg, mean_motion_revday):
    """Expected RAAN precession rate due to J2 (Vallado §9.4).

    Returns degrees/day. Negative for prograde orbits.
    """
    J2 = 1.08263e-3
    Re = 6378.137  # km
    mu = 398600.4418  # km^3/s^2
    n_rad_s = mean_motion_revday * 2 * math.pi / 86400.0
    if n_rad_s <= 0:
        return 0.0
    a = (mu / (n_rad_s ** 2)) ** (1.0 / 3.0)  # semi-major axis in km
    if a <= Re:
        return 0.0
    cos_i = math.cos(math.radians(inclination_deg))
    raan_rate = -1.5 * n_rad_s * J2 * (Re / a) ** 2 * cos_i
    return math.degrees(raan_rate) * 86400.0 / (2 * math.pi)  # deg/day


def detect_maneuvers(current_gp_data):
    """Compare current TLEs against stored history to detect orbital maneuvers.

    Returns list of maneuver alert dicts. Only runs when _tle_history is populated
    (i.e., after the second CelesTrak fetch or from persisted history).

    Thresholds from Lemmens & Krag (2014), Kim et al. (2021).
    """
    if not _tle_history:
        return []

    alerts = []
    for sat in current_gp_data:
        norad_id = sat.get("NORAD_CAT_ID")
        if norad_id is None:
            continue
        norad_id = int(norad_id)
        prev = _tle_history.get(norad_id)
        if prev is None:
            continue

        cur_mm = sat.get("MEAN_MOTION")
        cur_inc = sat.get("INCLINATION")
        cur_ecc = sat.get("ECCENTRICITY")
        cur_raan = sat.get("RA_OF_ASC_NODE")
        prev_mm = prev.get("MEAN_MOTION")
        prev_inc = prev.get("INCLINATION")
        prev_ecc = prev.get("ECCENTRICITY")
        prev_raan = prev.get("RA_OF_ASC_NODE")

        if any(v is None for v in (cur_mm, cur_inc, cur_ecc, cur_raan,
                                    prev_mm, prev_inc, prev_ecc, prev_raan)):
            continue

        # Convert mean motion (rev/day) to period (minutes)
        cur_period = 1440.0 / cur_mm if cur_mm > 0 else 0
        prev_period = 1440.0 / prev_mm if prev_mm > 0 else 0

        reasons = []
        t = _MANEUVER_THRESHOLDS

        delta_period = abs(cur_period - prev_period)
        if delta_period > t["period_min"]:
            reasons.append(f"period Δ{delta_period:+.3f} min")

        delta_inc = abs(cur_inc - prev_inc)
        if delta_inc > t["inclination_deg"]:
            reasons.append(f"inclination Δ{delta_inc:+.4f}°")

        delta_ecc = abs(cur_ecc - prev_ecc)
        if delta_ecc > t["eccentricity"]:
            reasons.append(f"eccentricity Δ{delta_ecc:+.6f}")

        # RAAN with J2 correction — only flag residual beyond expected precession
        epoch_str = sat.get("EPOCH", "")
        try:
            epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
            epoch_ts = epoch_dt.timestamp()
        except (ValueError, TypeError):
            epoch_ts = 0
        prev_epoch_ts = prev.get("epoch_ts", 0)
        dt_days = (epoch_ts - prev_epoch_ts) / 86400.0 if (epoch_ts and prev_epoch_ts) else 1.0
        if dt_days > 0:
            expected_raan_drift = _j2_raan_rate(cur_inc, cur_mm) * dt_days
            actual_raan_change = cur_raan - prev_raan
            # Normalize to [-180, 180]
            actual_raan_change = (actual_raan_change + 180) % 360 - 180
            raan_residual = abs(actual_raan_change - expected_raan_drift)
            if raan_residual > t["raan_residual_deg"]:
                reasons.append(f"RAAN residual {raan_residual:.3f}° (J2-corrected)")

        if reasons:
            alerts.append({
                "norad_id": norad_id,
                "name": sat.get("OBJECT_NAME", "UNKNOWN"),
                "type": "maneuver",
                "reasons": reasons,
                "epoch": sat.get("EPOCH", ""),
                "delta_period_min": round(delta_period, 4),
                "delta_inclination_deg": round(delta_inc, 5),
                "delta_eccentricity": round(delta_ecc, 7),
            })

    logger.info(f"Satellites: Maneuver scan — {len(alerts)} detections from {len(current_gp_data)} objects")
    return alerts


def detect_decay_anomalies(current_gp_data):
    """Flag satellites with abnormal mean-motion change rates (possible decay).

    A rapidly increasing mean motion indicates orbital decay — the satellite
    is losing altitude. Normal LEO drag is ~0.001 rev/day/day.
    """
    if not _tle_history:
        return []

    alerts = []
    for sat in current_gp_data:
        norad_id = sat.get("NORAD_CAT_ID")
        if norad_id is None:
            continue
        norad_id = int(norad_id)
        prev = _tle_history.get(norad_id)
        if prev is None:
            continue

        cur_mm = sat.get("MEAN_MOTION")
        prev_mm = prev.get("MEAN_MOTION")
        if cur_mm is None or prev_mm is None:
            continue

        epoch_str = sat.get("EPOCH", "")
        try:
            epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
            epoch_ts = epoch_dt.timestamp()
        except (ValueError, TypeError):
            continue
        prev_epoch_ts = prev.get("epoch_ts", 0)
        dt_days = (epoch_ts - prev_epoch_ts) / 86400.0 if (epoch_ts and prev_epoch_ts) else 0
        if dt_days < 0.5:
            continue  # Need at least 12h between TLEs for meaningful comparison

        mm_rate = (cur_mm - prev_mm) / dt_days  # rev/day per day
        if abs(mm_rate) > _DECAY_MM_RATE_THRESHOLD:
            cur_alt_km = (8681663.7 / (cur_mm ** (2.0 / 3.0))) - 6371.0 if cur_mm > 0 else 0
            alerts.append({
                "norad_id": norad_id,
                "name": sat.get("OBJECT_NAME", "UNKNOWN"),
                "type": "decay_anomaly",
                "mm_rate": round(mm_rate, 6),
                "current_mm": round(cur_mm, 4),
                "approx_alt_km": round(cur_alt_km, 1),
                "epoch": sat.get("EPOCH", ""),
                "dt_days": round(dt_days, 2),
            })

    logger.info(f"Satellites: Decay scan — {len(alerts)} anomalies detected")
    return alerts


def compute_overflights(gp_data, bbox, hours=24, step_minutes=10):
    """Count unique satellites whose ground track enters a bounding box.

    Args:
        gp_data: Full GP catalog (list of dicts with orbital elements).
        bbox: Dict with keys 's', 'w', 'n', 'e' (degrees).
        hours: Look-back window (default 24h).
        step_minutes: Sampling interval (default 10 min).

    Returns dict with total count and per-mission breakdown.
    Uses SGP4 propagation — CPU cost is ~O(catalog_size × timesteps).
    Only propagates satellites that could plausibly overfly the bbox latitude range.
    """
    if not gp_data or not bbox:
        return {"total": 0, "by_mission": {}, "satellites": []}

    south, west = bbox["s"], bbox["w"]
    north, east = bbox["n"], bbox["e"]
    now = datetime.utcnow()
    steps = int(hours * 60 / step_minutes)

    # Pre-filter: only propagate sats whose inclination allows them to reach bbox latitude
    max_lat = max(abs(south), abs(north))
    candidates = [s for s in gp_data if s.get("INCLINATION") is not None
                  and s.get("INCLINATION") >= max_lat * 0.8]  # 20% margin

    seen_ids = set()
    results = []
    by_mission = {}

    for s in candidates:
        norad_id = s.get("NORAD_CAT_ID")
        mean_motion = s.get("MEAN_MOTION")
        ecc = s.get("ECCENTRICITY")
        incl = s.get("INCLINATION")
        raan = s.get("RA_OF_ASC_NODE")
        argp = s.get("ARG_OF_PERICENTER")
        ma = s.get("MEAN_ANOMALY")
        bstar = s.get("BSTAR", 0)
        epoch_str = s.get("EPOCH", "")

        if any(v is None for v in (mean_motion, ecc, incl, raan, argp, ma, epoch_str)):
            continue

        try:
            epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
            epoch_jd, epoch_fr = jday(
                epoch_dt.year, epoch_dt.month, epoch_dt.day,
                epoch_dt.hour, epoch_dt.minute, epoch_dt.second,
            )
            sat_obj = Satrec()
            sat_obj.sgp4init(
                WGS72, "i", norad_id or 0,
                (epoch_jd + epoch_fr) - 2433281.5,
                bstar, 0.0, 0.0, ecc,
                math.radians(argp), math.radians(incl), math.radians(ma),
                mean_motion * 2 * math.pi / 1440.0, math.radians(raan),
            )
        except (ValueError, TypeError):
            continue

        for step in range(steps):
            t = now - timedelta(minutes=step * step_minutes)
            jd_t, fr_t = jday(t.year, t.month, t.day, t.hour, t.minute, t.second)
            e, r, _ = sat_obj.sgp4(jd_t, fr_t)
            if e != 0:
                continue
            x, y, z = r
            gmst = _gmst(jd_t + fr_t)
            lng_rad = math.atan2(y, x) - gmst
            lat_deg = math.degrees(math.atan2(z, math.sqrt(x * x + y * y)))
            lng_deg = math.degrees(lng_rad) % 360
            if lng_deg > 180:
                lng_deg -= 360

            # Check bounding box (handles antimeridian crossing)
            lat_in = south <= lat_deg <= north
            if west <= east:
                lng_in = west <= lng_deg <= east
            else:
                lng_in = lng_deg >= west or lng_deg <= east

            if lat_in and lng_in and norad_id not in seen_ids:
                seen_ids.add(norad_id)
                name = s.get("OBJECT_NAME", "UNKNOWN")
                # Classify for mission breakdown
                mission = "unknown"
                for key, meta in _SAT_INTEL_DB:
                    if key.upper() in name.upper():
                        mission = meta.get("mission", "unknown")
                        break
                by_mission[mission] = by_mission.get(mission, 0) + 1
                results.append({"norad_id": norad_id, "name": name, "mission": mission})
                break  # Already counted this sat, move to next

    return {"total": len(results), "by_mission": by_mission, "satellites": results}


def _parse_tle_to_gp(name, norad_id, line1, line2):
    """Convert TLE two-line element to CelesTrak GP-style dict."""
    try:
        incl = float(line2[8:16].strip())
        raan = float(line2[17:25].strip())
        ecc = float("0." + line2[26:33].strip())
        argp = float(line2[34:42].strip())
        ma = float(line2[43:51].strip())
        mm = float(line2[52:63].strip())
        bstar_str = line1[53:61].strip()
        if bstar_str:
            mantissa = float(bstar_str[:-2]) / 1e5
            exponent = int(bstar_str[-2:])
            bstar = mantissa * (10**exponent)
        else:
            bstar = 0.0
        epoch_yr = int(line1[18:20])
        epoch_day = float(line1[20:32].strip())
        year = 2000 + epoch_yr if epoch_yr < 57 else 1900 + epoch_yr
        epoch_dt = datetime(year, 1, 1) + timedelta(days=epoch_day - 1)
        return {
            "OBJECT_NAME": name,
            "NORAD_CAT_ID": norad_id,
            "MEAN_MOTION": mm,
            "ECCENTRICITY": ecc,
            "INCLINATION": incl,
            "RA_OF_ASC_NODE": raan,
            "ARG_OF_PERICENTER": argp,
            "MEAN_ANOMALY": ma,
            "BSTAR": bstar,
            "EPOCH": epoch_dt.strftime("%Y-%m-%dT%H:%M:%S"),
        }
    except (ValueError, TypeError, IndexError, KeyError):
        return None


def _annotate_celestrak_group(records: list[dict], group: str) -> list[dict]:
    meta = _CELESTRAK_VISIBLE_GROUPS.get(group, {})
    out = []
    for sat in records:
        if not isinstance(sat, dict):
            continue
        item = dict(sat)
        item["_SB_GROUP"] = group
        if meta:
            item["_SB_GROUP_META"] = meta
        out.append(item)
    return out


def _fetch_visible_celestrak_catalog(headers: dict | None = None) -> list[dict]:
    """Fetch bounded CelesTrak groups used by the visible satellite layer.

    The full ``active`` catalog is too large and frequently times out on local
    startup. These groups cover the visible operational set users expect
    without pulling Starlink-scale constellations into the map.
    """
    headers = headers or {}
    merged: dict[int, dict] = {}
    for group in _CELESTRAK_VISIBLE_GROUPS:
        url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json"
        try:
            response = fetch_with_curl(url, timeout=15, headers=headers)
            if response.status_code != 200:
                logger.debug("Satellites: CelesTrak group %s returned HTTP %s", group, response.status_code)
                continue
            gp_data = response.json()
            if not isinstance(gp_data, list):
                continue
            for sat in _annotate_celestrak_group(gp_data, group):
                norad_id = sat.get("NORAD_CAT_ID")
                if norad_id is None:
                    continue
                merged[int(norad_id)] = sat
            time.sleep(0.35)
        except (
            requests.RequestException,
            ConnectionError,
            TimeoutError,
            ValueError,
            KeyError,
            json.JSONDecodeError,
            OSError,
        ) as e:
            logger.warning("Satellites: Failed to fetch CelesTrak group %s: %s", group, e)
    return list(merged.values())


def _fetch_satellites_from_tle_api():
    """Fallback: fetch satellite TLEs from tle.ivanstanojevic.me when CelesTrak is blocked."""
    search_terms = set(_TLE_VISIBLE_FALLBACK_TERMS)
    for key, _ in _SAT_INTEL_DB:
        term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
        search_terms.add(term)

    all_results = []
    seen_ids = set()
    for term in search_terms:
        try:
            url = f"https://tle.ivanstanojevic.me/api/tle/?search={term}&page_size=100&format=json"
            response = fetch_with_curl(url, timeout=8)
            if response.status_code != 200:
                continue
            data = response.json()
            for member in data.get("member", []):
                gp = _parse_tle_to_gp(
                    member.get("name", "UNKNOWN"),
                    member.get("satelliteId"),
                    member.get("line1", ""),
                    member.get("line2", ""),
                )
                if gp:
                    sat_id = gp.get("NORAD_CAT_ID")
                    if sat_id not in seen_ids:
                        seen_ids.add(sat_id)
                        if term in _TLE_VISIBLE_FALLBACK_TERMS:
                            gp["_SB_GROUP"] = f"tle:{term}"
                            gp["_SB_GROUP_META"] = _TLE_VISIBLE_FALLBACK_TERMS[term]
                        all_results.append(gp)
                        if len(all_results) >= _MAX_VISIBLE_SATELLITE_CATALOG:
                            return all_results
            time.sleep(0.15)  # Polite delay between requests
        except (
            requests.RequestException,
            ConnectionError,
            TimeoutError,
            ValueError,
            KeyError,
            json.JSONDecodeError,
            OSError,
        ) as e:
            logger.debug(f"TLE fallback search '{term}' failed: {e}")

    return all_results


def fetch_satellites():
    from services.fetchers._store import is_any_active

    if not is_any_active("satellites"):
        return
    sats = []
    maneuver_alerts = []
    decay_alerts = []
    starlink_summary = {}
    data = None
    classified = None
    try:
        now_ts = time.time()

        # On first call, load TLE history from disk for maneuver detection
        if not _tle_history:
            _load_tle_history()

        # On first call, try disk cache before hitting CelesTrak
        if _sat_gp_cache["data"] is None:
            disk_data = _load_sat_cache()
            if disk_data:
                import os

                cache_mtime = (
                    os.path.getmtime(str(_SAT_CACHE_PATH)) if _SAT_CACHE_PATH.exists() else 0
                )
                _sat_gp_cache["data"] = disk_data
                _sat_gp_cache["last_fetch"] = cache_mtime  # real fetch time so 24h check works
                _sat_gp_cache["source"] = "disk_cache"
                logger.info(
                    f"Satellites: Bootstrapped from disk cache ({len(disk_data)} records, "
                    f"{(now_ts - cache_mtime) / 3600:.1f}h old)"
                )

        if (
            _sat_gp_cache["data"] is None
            or len(_sat_gp_cache.get("data") or []) < _MIN_VISIBLE_SATELLITE_CATALOG
            or (now_ts - _sat_gp_cache["last_fetch"]) > _CELESTRAK_FETCH_INTERVAL
        ):
            # Build conditional request headers (CelesTrak fair use)
            headers = {}
            if _sat_gp_cache.get("last_modified"):
                headers["If-Modified-Since"] = _sat_gp_cache["last_modified"]

            visible_data = _fetch_visible_celestrak_catalog(headers=headers)
            if len(visible_data) >= _MIN_VISIBLE_SATELLITE_CATALOG:
                _sat_gp_cache["data"] = visible_data
                _sat_gp_cache["last_fetch"] = now_ts
                _sat_gp_cache["source"] = "celestrak_visible_groups"
                _save_sat_cache(visible_data)
                _snapshot_current_tles(visible_data)
                logger.info(
                    "Satellites: Downloaded %d GP records from visible CelesTrak groups",
                    len(visible_data),
                )

            gp_urls = [
                "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
                "https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
            ]

            for url in gp_urls:
                if len(_sat_gp_cache.get("data") or []) >= _MIN_VISIBLE_SATELLITE_CATALOG:
                    break
                try:
                    response = fetch_with_curl(url, timeout=15, headers=headers)
                    if response.status_code == 304:
                        # Data unchanged — reset timer without re-downloading
                        _sat_gp_cache["last_fetch"] = now_ts
                        logger.info(
                            f"Satellites: CelesTrak returned 304 Not Modified (data unchanged)"
                        )
                        break
                    if response.status_code == 200:
                        gp_data = response.json()
                        if isinstance(gp_data, list) and len(gp_data) > 100:
                            _sat_gp_cache["data"] = gp_data
                            _sat_gp_cache["last_fetch"] = now_ts
                            _sat_gp_cache["source"] = "celestrak"
                            # Store Last-Modified header for future conditional requests
                            if hasattr(response, "headers"):
                                lm = response.headers.get("Last-Modified")
                                if lm:
                                    _sat_gp_cache["last_modified"] = lm
                            _save_sat_cache(gp_data)
                            # Snapshot current TLEs as history before overwriting
                            # (the old _tle_history becomes the comparison baseline)
                            _snapshot_current_tles(gp_data)
                            logger.info(
                                f"Satellites: Downloaded {len(gp_data)} GP records from CelesTrak"
                            )
                            break
                except (
                    requests.RequestException,
                    ConnectionError,
                    TimeoutError,
                    ValueError,
                    KeyError,
                    json.JSONDecodeError,
                    OSError,
                ) as e:
                    logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
                    continue

            if (
                _sat_gp_cache["data"] is None
                or len(_sat_gp_cache.get("data") or []) < _MIN_VISIBLE_SATELLITE_CATALOG
            ):
                logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...")
                try:
                    fallback_data = _fetch_satellites_from_tle_api()
                    if fallback_data and len(fallback_data) > 10:
                        _sat_gp_cache["data"] = fallback_data
                        _sat_gp_cache["last_fetch"] = now_ts
                        _sat_gp_cache["source"] = "tle_api"
                        _save_sat_cache(fallback_data)
                        logger.info(
                            f"Satellites: Got {len(fallback_data)} records from TLE fallback API"
                        )
                except (
                    requests.RequestException,
                    ConnectionError,
                    TimeoutError,
                    ValueError,
                    KeyError,
                    OSError,
                ) as e:
                    logger.error(f"Satellites: TLE fallback also failed: {e}")

            if _sat_gp_cache["data"] is None:
                disk_data = _load_sat_cache()
                if disk_data:
                    _sat_gp_cache["data"] = disk_data
                    _sat_gp_cache["last_fetch"] = now_ts - (_CELESTRAK_FETCH_INTERVAL - 300)
                    _sat_gp_cache["source"] = "disk_cache"

        data = _sat_gp_cache["data"]
        if not data:
            logger.warning("No satellite GP data available from any source")
            with _data_lock:
                latest_data["satellites"] = sats
            return

        if (
            _sat_classified_cache["gp_fetch_ts"] == _sat_gp_cache["last_fetch"]
            and _sat_classified_cache["data"]
        ):
            classified = _sat_classified_cache["data"]
            starlink_summary = _sat_classified_cache.get("starlink_summary", {})
            logger.info(
                f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)"
            )
        else:
            classified = []
            starlink_count = 0
            starlink_shells = {}  # inclination shell → count
            for sat in data:
                name = sat.get("OBJECT_NAME", "UNKNOWN").upper()
                intel = None
                for key, meta in _SAT_INTEL_DB:
                    if key.upper() in name:
                        intel = dict(meta)
                        break
                if not intel:
                    # Secondary classification via SATCAT owner code
                    owner = sat.get("OWNER", sat.get("OBJECT_OWNER", ""))
                    if owner in _OWNER_CODE_MAP:
                        intel = {"country": _OWNER_CODE_MAP[owner], "mission": "general", "sat_type": "Unclassified"}
                if not intel and sat.get("_SB_GROUP_META"):
                    intel = dict(sat["_SB_GROUP_META"])
                    intel.setdefault("country", "Unknown")
                if not intel:
                    continue

                # Starlink: count and summarize but don't propagate individually
                # (6000+ sats would be too expensive to position every 60s)
                if intel.get("mission") == "starlink":
                    starlink_count += 1
                    inc = sat.get("INCLINATION")
                    if inc is not None:
                        shell_key = f"{round(inc, 0):.0f}°"
                        starlink_shells[shell_key] = starlink_shells.get(shell_key, 0) + 1
                    continue  # Skip individual propagation

                entry = {
                    "id": sat.get("NORAD_CAT_ID"),
                    "name": sat.get("OBJECT_NAME", "UNKNOWN"),
                    "MEAN_MOTION": sat.get("MEAN_MOTION"),
                    "ECCENTRICITY": sat.get("ECCENTRICITY"),
                    "INCLINATION": sat.get("INCLINATION"),
                    "RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"),
                    "ARG_OF_PERICENTER": sat.get("ARG_OF_PERICENTER"),
                    "MEAN_ANOMALY": sat.get("MEAN_ANOMALY"),
                    "BSTAR": sat.get("BSTAR"),
                    "EPOCH": sat.get("EPOCH"),
                }
                entry.update(intel)
                classified.append(entry)

            starlink_summary = {
                "total": starlink_count,
                "shells": starlink_shells,
            }
            _sat_classified_cache["data"] = classified
            _sat_classified_cache["starlink_summary"] = starlink_summary
            _sat_classified_cache["gp_fetch_ts"] = _sat_gp_cache["last_fetch"]
            logger.info(
                f"Satellites: {len(classified)} intel-classified, "
                f"{starlink_count} Starlink (summarized), "
                f"out of {len(data)} total in catalog"
            )

        all_sats = classified

        # ── Run analysis detectors against the full GP catalog ──────────────
        # These use cached TLEs only — no extra network requests.
        maneuver_alerts = []
        decay_alerts = []
        try:
            maneuver_alerts = detect_maneuvers(data)
        except (ValueError, TypeError, KeyError, ZeroDivisionError) as e:
            logger.error(f"Satellites: Maneuver detection error: {e}")
        try:
            decay_alerts = detect_decay_anomalies(data)
        except (ValueError, TypeError, KeyError, ZeroDivisionError) as e:
            logger.error(f"Satellites: Decay detection error: {e}")

        now = datetime.utcnow()
        jd, fr = jday(
            now.year, now.month, now.day, now.hour, now.minute, now.second + now.microsecond / 1e6
        )

        for source_sat in all_sats:
            # Keep the classified cache immutable. The render payload below
            # strips orbital fields after propagation, and mutating the cached
            # entry would make the next refresh unable to position satellites.
            s = dict(source_sat)
            try:
                mean_motion = s.get("MEAN_MOTION")
                ecc = s.get("ECCENTRICITY")
                incl = s.get("INCLINATION")
                raan = s.get("RA_OF_ASC_NODE")
                argp = s.get("ARG_OF_PERICENTER")
                ma = s.get("MEAN_ANOMALY")
                bstar = s.get("BSTAR", 0)
                epoch_str = s.get("EPOCH")
                norad_id = s.get("id", 0)

                if mean_motion is None or ecc is None or incl is None:
                    continue

                epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
                epoch_jd, epoch_fr = jday(
                    epoch_dt.year,
                    epoch_dt.month,
                    epoch_dt.day,
                    epoch_dt.hour,
                    epoch_dt.minute,
                    epoch_dt.second,
                )

                sat_obj = Satrec()
                sat_obj.sgp4init(
                    WGS72,
                    "i",
                    norad_id,
                    (epoch_jd + epoch_fr) - 2433281.5,
                    bstar,
                    0.0,
                    0.0,
                    ecc,
                    math.radians(argp),
                    math.radians(incl),
                    math.radians(ma),
                    mean_motion * 2 * math.pi / 1440.0,
                    math.radians(raan),
                )

                e, r, v = sat_obj.sgp4(jd, fr)
                if e != 0:
                    continue

                x, y, z = r
                gmst = _gmst(jd + fr)
                lng_rad = math.atan2(y, x) - gmst
                lat_rad = math.atan2(z, math.sqrt(x * x + y * y))
                alt_km = math.sqrt(x * x + y * y + z * z) - 6371.0

                s["lat"] = round(math.degrees(lat_rad), 4)
                lng_deg = math.degrees(lng_rad) % 360
                s["lng"] = round(lng_deg - 360 if lng_deg > 180 else lng_deg, 4)
                s["alt_km"] = round(alt_km, 1)

                vx, vy, vz = v
                omega_e = 7.2921159e-5
                vx_g = vx + omega_e * y
                vy_g = vy - omega_e * x
                vz_g = vz
                cos_lat = math.cos(lat_rad)
                sin_lat = math.sin(lat_rad)
                cos_lng = math.cos(lng_rad + gmst)
                sin_lng = math.sin(lng_rad + gmst)
                v_east = -sin_lng * vx_g + cos_lng * vy_g
                v_north = -sin_lat * cos_lng * vx_g - sin_lat * sin_lng * vy_g + cos_lat * vz_g
                ground_speed_kms = math.sqrt(v_east**2 + v_north**2)
                s["speed_knots"] = round(ground_speed_kms * 1943.84, 1)
                heading_rad = math.atan2(v_east, v_north)
                s["heading"] = round(math.degrees(heading_rad) % 360, 1)
                sat_name = s.get("name", "")
                usa_match = re.search(r"USA[\s\-]*(\d+)", sat_name)
                if usa_match:
                    s["wiki"] = f"https://en.wikipedia.org/wiki/USA-{usa_match.group(1)}"
                for k in (
                    "MEAN_MOTION",
                    "ECCENTRICITY",
                    "INCLINATION",
                    "RA_OF_ASC_NODE",
                    "ARG_OF_PERICENTER",
                    "MEAN_ANOMALY",
                    "BSTAR",
                    "EPOCH",
                    "tle1",
                    "tle2",
                ):
                    s.pop(k, None)
                sats.append(s)
            except (ValueError, TypeError, KeyError, AttributeError, ZeroDivisionError):
                continue

        logger.info(f"Satellites: {len(classified)} classified, {len(sats)} positioned")
    except (
        requests.RequestException,
        ConnectionError,
        TimeoutError,
        ValueError,
        KeyError,
        json.JSONDecodeError,
        OSError,
    ) as e:
        logger.error(f"Error fetching satellites: {e}")
    if sats:
        with _data_lock:
            latest_data["satellites"] = sats
            latest_data["satellite_source"] = _sat_gp_cache.get("source", "none")
            latest_data["satellite_analysis"] = {
                "maneuvers": maneuver_alerts,
                "decay_anomalies": decay_alerts,
                "starlink": starlink_summary,
                "catalog_size": len(data) if data else 0,
                "classified_count": len(classified) if classified else 0,
            }
        _mark_fresh("satellites")
    else:
        with _data_lock:
            if not latest_data.get("satellites"):
                latest_data["satellites"] = []
                latest_data["satellite_source"] = "none"
