"""
TinyGS LoRa satellite tracker — SGP4 orbit propagation + TinyGS telemetry.

Primary position source: CelesTrak TLEs propagated via SGP4 (always available).
Secondary validation: TinyGS API confirms satellite is actively transmitting LoRa
and provides modulation/frequency/status metadata.

CelesTrak Fair Use: TLEs fetched at most once per 24 hours, cached to disk.
TinyGS: polled every 5 minutes (their server has limited capacity).
"""

import json
import logging
import math
import time
from datetime import datetime
from pathlib import Path

import requests
from sgp4.api import Satrec, WGS72, jday



def _tinygs_user_agent(purpose: str) -> str:
    """Round 7a: per-install handle for CelesTrak / TinyGS attribution."""
    from services.network_utils import outbound_user_agent
    return outbound_user_agent(f"tinygs-{purpose}")

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# CelesTrak TLE cache (24-hour refresh, disk-backed)
# ---------------------------------------------------------------------------
_CELESTRAK_FETCH_INTERVAL = 86400  # 24 hours
_TLE_CACHE_PATH = Path(__file__).parent.parent / "data" / "tinygs_tle_cache.json"
_tle_cache: dict = {"data": None, "last_fetch": 0.0}

# TinyGS API telemetry cache
_TINYGS_FETCH_INTERVAL = 1800  # 30 minutes (TinyGS has limited infra, avoid IP bans)
_tinygs_telemetry: dict[str, dict] = {}  # name_key → {modulation, frequency, status}
_tinygs_last_fetch: float = 0.0
_tinygs_known_names: set[str] = set()  # names seen from TinyGS API

# Final result cache
_last_result: list[dict] = []

# CelesTrak GP groups containing LoRa / amateur cubesats
_CELESTRAK_GROUPS = ["amateur", "cubesat"]
_CELESTRAK_BASE = "https://celestrak.org/NORAD/elements/gp.php"


def _gmst(jd_ut1: float) -> float:
    """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
    )
    return (gmst_sec % 86400) / 86400.0 * 2 * math.pi


# ---------------------------------------------------------------------------
# CelesTrak TLE fetch + disk cache
# ---------------------------------------------------------------------------


def _load_tle_cache() -> list[dict] | None:
    """Load TLE data from disk cache."""
    try:
        if _TLE_CACHE_PATH.exists():
            import os

            age = time.time() - os.path.getmtime(str(_TLE_CACHE_PATH))
            if age < _CELESTRAK_FETCH_INTERVAL * 2:  # accept up to 48h old cache
                data = json.loads(_TLE_CACHE_PATH.read_text(encoding="utf-8"))
                if isinstance(data, list) and len(data) > 0:
                    return data
    except (IOError, json.JSONDecodeError, ValueError) as e:
        logger.warning("TinyGS TLE: disk cache load failed: %s", e)
    return None


def _save_tle_cache(data: list[dict]) -> None:
    """Save TLE data to disk cache."""
    try:
        _TLE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
        _TLE_CACHE_PATH.write_text(
            json.dumps(data, ensure_ascii=False), encoding="utf-8"
        )
    except (IOError, OSError) as e:
        logger.warning("TinyGS TLE: disk cache save failed: %s", e)


def _fetch_celestrak_tles() -> list[dict]:
    """Fetch GP data from CelesTrak for amateur + cubesat groups."""
    global _tle_cache

    now = time.time()

    # Return memory cache if fresh
    if _tle_cache["data"] and now - _tle_cache["last_fetch"] < _CELESTRAK_FETCH_INTERVAL:
        return _tle_cache["data"]

    # Try disk cache first
    if not _tle_cache["data"]:
        disk = _load_tle_cache()
        if disk:
            _tle_cache["data"] = disk
            _tle_cache["last_fetch"] = now - _CELESTRAK_FETCH_INTERVAL + 3600  # re-check in 1h
            logger.info("TinyGS TLE: loaded %d elements from disk cache", len(disk))

    # Fetch fresh from CelesTrak
    all_sats: dict[int, dict] = {}  # keyed by NORAD_CAT_ID to deduplicate
    for group in _CELESTRAK_GROUPS:
        try:
            resp = requests.get(
                _CELESTRAK_BASE,
                params={"GROUP": group, "FORMAT": "json"},
                timeout=20,
                headers={
                    "User-Agent": _tinygs_user_agent("celestrak"),
                    "Accept": "application/json",
                },
            )
            resp.raise_for_status()
            for s in resp.json():
                norad_id = s.get("NORAD_CAT_ID")
                if norad_id:
                    all_sats[norad_id] = s
            logger.info("TinyGS TLE: fetched %s group (%d sats)", group, len(resp.json()))
        except (requests.RequestException, ValueError, KeyError) as e:
            logger.warning("TinyGS TLE: CelesTrak %s fetch failed: %s", group, e)

    if all_sats:
        result = list(all_sats.values())
        _tle_cache["data"] = result
        _tle_cache["last_fetch"] = now
        _save_tle_cache(result)
        logger.info("TinyGS TLE: cached %d total orbital elements", len(result))
        return result

    # Fall back to whatever we have
    return _tle_cache["data"] or []


# ---------------------------------------------------------------------------
# SGP4 propagation
# ---------------------------------------------------------------------------


def _propagate_all(gp_data: list[dict]) -> dict[int, dict]:
    """Propagate all satellites to current time via SGP4.

    Returns dict keyed by NORAD_CAT_ID with position/velocity data.
    """
    now = datetime.utcnow()
    jd, fr = jday(
        now.year, now.month, now.day,
        now.hour, now.minute,
        now.second + now.microsecond / 1e6,
    )

    results: dict[int, dict] = {}
    for s in gp_data:
        try:
            norad_id = s.get("NORAD_CAT_ID", 0)
            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")
            obj_name = s.get("OBJECT_NAME", "")

            if mean_motion is None or ecc is None or incl is None or not epoch_str:
                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

            lat = math.degrees(lat_rad)
            lng_deg = math.degrees(lng_rad) % 360
            lng = lng_deg - 360 if lng_deg > 180 else lng_deg

            # Ground-relative velocity for heading/speed
            vx, vy, vz = v
            omega_e = 7.2921159e-5
            vx_g = vx + omega_e * y
            vy_g = vy - omega_e * x
            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
            ground_speed_kms = math.sqrt(v_east**2 + v_north**2)
            speed_knots = ground_speed_kms * 1943.84
            heading = math.degrees(math.atan2(v_east, v_north)) % 360

            results[norad_id] = {
                "name": obj_name,
                "lat": round(lat, 4),
                "lng": round(lng, 4),
                "alt_km": round(alt_km, 1),
                "heading": round(heading, 1),
                "speed_knots": round(speed_knots, 0),
                "norad_id": norad_id,
            }
        except (ValueError, TypeError, KeyError, OverflowError):
            continue

    return results


# ---------------------------------------------------------------------------
# TinyGS API telemetry fetch
# ---------------------------------------------------------------------------


def _name_key(name: str) -> str:
    """Normalise a satellite name for fuzzy matching."""
    return name.upper().replace("-", "").replace("_", "").replace(" ", "")


def _fetch_tinygs_telemetry() -> None:
    """Fetch active satellite list from TinyGS for telemetry metadata."""
    global _tinygs_last_fetch, _tinygs_telemetry, _tinygs_known_names

    now = time.time()
    if now - _tinygs_last_fetch < _TINYGS_FETCH_INTERVAL:
        return

    try:
        resp = requests.get(
            "https://api.tinygs.com/v1/satellitesWorldmap",
            timeout=15,
            headers={
                "Accept": "application/json",
                "User-Agent": _tinygs_user_agent("tinygs"),
            },
        )
        resp.raise_for_status()
        new_telemetry: dict[str, dict] = {}
        names: set[str] = set()
        for s in resp.json():
            display_name = (s.get("displayName") or s.get("name") or "")[:80]
            if not display_name:
                continue
            key = _name_key(display_name)
            names.add(key)
            tags = s.get("tags") or {}
            new_telemetry[key] = {
                "display_name": display_name,
                "status": s.get("status", ""),
                "modulation": ", ".join(tags.get("modulation", [])),
                "frequency": ", ".join(str(f) for f in tags.get("frequency", [])),
            }
        _tinygs_telemetry = new_telemetry
        _tinygs_known_names = names
        _tinygs_last_fetch = now
        logger.info("TinyGS telemetry: fetched %d active satellites", len(new_telemetry))
    except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
        logger.warning("TinyGS telemetry fetch failed (SGP4 still active): %s", e)
        # Keep existing telemetry — don't clear on failure


# ---------------------------------------------------------------------------
# Merge SGP4 positions + TinyGS telemetry
# ---------------------------------------------------------------------------


def _match_name(celestrak_name: str) -> dict | None:
    """Try to match a CelesTrak object name to TinyGS telemetry."""
    key = _name_key(celestrak_name)
    # Exact match
    if key in _tinygs_telemetry:
        return _tinygs_telemetry[key]
    # Substring match — CelesTrak name contains TinyGS name or vice versa
    for tgs_key, tgs_data in _tinygs_telemetry.items():
        if tgs_key in key or key in tgs_key:
            return tgs_data
    return None


def fetch_tinygs_satellites() -> list[dict]:
    """Fetch LoRa satellite positions via SGP4 + TinyGS telemetry merge.

    1. Propagate cached CelesTrak TLEs via SGP4 (instant, no network needed)
    2. Attempt TinyGS API for telemetry (modulation, frequency, status)
    3. Merge: SGP4 provides position, TinyGS provides metadata
    4. Filter to only satellites known to TinyGS (if we have TinyGS data)
    """
    global _last_result

    # Step 1: Get TLE data (from cache or CelesTrak)
    gp_data = _fetch_celestrak_tles()
    if not gp_data:
        logger.warning("TinyGS: no TLE data available")
        return _last_result or []

    # Step 2: Try to fetch TinyGS telemetry (non-blocking, uses cache)
    _fetch_tinygs_telemetry()

    # Step 3: Propagate all satellites via SGP4
    propagated = _propagate_all(gp_data)
    if not propagated:
        logger.warning("TinyGS: SGP4 propagation returned no results")
        return _last_result or []

    # Step 4: Merge and filter
    sats: list[dict] = []
    have_tinygs = bool(_tinygs_known_names)

    for norad_id, pos in propagated.items():
        celestrak_name = pos["name"]
        telemetry = _match_name(celestrak_name)

        # If we have TinyGS data, only show satellites that TinyGS knows about
        # (filters out non-LoRa amateur/cubesats from the CelesTrak groups)
        if have_tinygs and telemetry is None:
            continue

        entry = {
            "name": telemetry["display_name"] if telemetry else celestrak_name,
            "lat": pos["lat"],
            "lng": pos["lng"],
            "heading": pos["heading"],
            "speed_knots": pos["speed_knots"],
            "alt_km": pos["alt_km"],
            "status": telemetry.get("status", "") if telemetry else "",
            "modulation": telemetry.get("modulation", "") if telemetry else "",
            "frequency": telemetry.get("frequency", "") if telemetry else "",
            "sgp4_propagated": True,
            "tinygs_confirmed": telemetry is not None,
        }
        sats.append(entry)

    # If we have no TinyGS data at all (API never responded), show all propagated
    # sats from the amateur group only (smaller, more relevant set)
    if not have_tinygs and not sats:
        for norad_id, pos in propagated.items():
            sats.append({
                "name": pos["name"],
                "lat": pos["lat"],
                "lng": pos["lng"],
                "heading": pos["heading"],
                "speed_knots": pos["speed_knots"],
                "alt_km": pos["alt_km"],
                "status": "",
                "modulation": "",
                "frequency": "",
                "sgp4_propagated": True,
                "tinygs_confirmed": False,
            })

    _last_result = sats
    logger.info(
        "TinyGS: %d satellites (SGP4 propagated, %d TinyGS confirmed)",
        len(sats),
        sum(1 for s in sats if s.get("tinygs_confirmed")),
    )
    return sats
