import json
import requests
import urllib3
from urllib.parse import urlsplit

from utilities.opensky import OpenSkyClient

try:
    from config import MIN_ALTITUDE, MAX_ALTITUDE, ZONE_HOME
except Exception:
    MIN_ALTITUDE = 0
    MAX_ALTITUDE = 100000
    ZONE_HOME = {"tl_y": 0.0, "tl_x": 0.0, "br_y": 0.0, "br_x": 0.0}

try:
    from config import _cfg
    _POSITION_CFG = _cfg.get("position_sources", {})
except Exception:
    _POSITION_CFG = {}

ADSLOL_FALLBACK_IPS = ("185.233.105.52",)


def _runtime_config():
    try:
        import config
        return config
    except Exception:
        return None


def _refresh_runtime_config():
    runtime = _runtime_config()
    if runtime:
        runtime.reload()


def _zone():
    runtime = _runtime_config()
    zone = getattr(runtime, "ZONE_HOME", ZONE_HOME) if runtime else ZONE_HOME
    north = _num(zone.get("tl_y"))
    south = _num(zone.get("br_y"))
    west = _num(zone.get("tl_x"))
    east = _num(zone.get("br_x"))
    return {
        "north": max(north, south),
        "south": min(north, south),
        "west": min(west, east),
        "east": max(west, east),
    }


def _enabled():
    runtime = _runtime_config()
    if runtime:
        return getattr(runtime, "POSITION_SOURCE_ENABLED", {})
    return _POSITION_CFG.get("enabled", {})


def _order():
    runtime = _runtime_config()
    if runtime:
        order = list(getattr(runtime, "POSITION_SOURCE_ORDER", ["OpenSky"]))
    else:
        order = list(_POSITION_CFG.get("order", ["OpenSky"]))
    for fallback in ("ADSBLol", "ADSBfi"):
        if fallback not in order:
            order.append(fallback)
    return order


def _num(value, default=0):
    try:
        return float(value)
    except (TypeError, ValueError):
        return default


def _altitude_ft(value):
    if value in ("ground", None, ""):
        return 0
    return int(_num(value))


def _parse_adsb_aircraft(ac):
    callsign = (ac.get("flight") or ac.get("callsign") or "").strip()
    lat = ac.get("lat")
    lon = ac.get("lon")
    if not callsign or lat is None or lon is None:
        return None

    altitude = _altitude_ft(ac.get("alt_baro", ac.get("alt_geom", 0)))
    runtime = _runtime_config()
    min_altitude = getattr(runtime, "MIN_ALTITUDE", MIN_ALTITUDE) if runtime else MIN_ALTITUDE
    max_altitude = getattr(runtime, "MAX_ALTITUDE", MAX_ALTITUDE) if runtime else MAX_ALTITUDE
    if altitude < min_altitude or altitude > max_altitude:
        return None

    if ac.get("alt_baro") == "ground" or bool(ac.get("ground", False)):
        return None

    position_age = ac.get("seen_pos", ac.get("seen"))
    if position_age not in (None, "") and _num(position_age, 9999) > 30:
        return None

    return {
        "icao24": (ac.get("hex") or ac.get("icao") or "").strip(),
        "callsign": callsign,
        "latitude": _num(lat),
        "longitude": _num(lon),
        "altitude": altitude,
        "ground_speed": int(_num(ac.get("gs", ac.get("speed", 0)))),
        "heading": _num(ac.get("track", ac.get("true_heading", 0))),
        "vertical_speed": int(_num(ac.get("baro_rate", ac.get("geom_rate", 0)))),
        "on_ground": False,
        "position_age_seconds": _num(position_age) if position_age not in (None, "") else None,
    }


def _within_zone(state):
    lat = state.get("latitude")
    lon = state.get("longitude")
    zone = _zone()
    return (
        lat is not None
        and lon is not None
        and zone["south"] <= lat <= zone["north"]
        and zone["west"] <= lon <= zone["east"]
    )


def _dedupe(states):
    freshest = {}
    order = []
    for state in states:
        key = state.get("icao24") or state.get("callsign")
        if not key:
            continue
        if key not in freshest:
            freshest[key] = state
            order.append(key)
            continue
        current_age = _num(freshest[key].get("position_age_seconds"), 9999)
        candidate_age = _num(state.get("position_age_seconds"), 9999)
        if candidate_age < current_age:
            freshest[key] = state
    return [freshest[key] for key in order]


def _zone_center_radius_nm():
    zone = _zone()
    north = zone["north"]
    south = zone["south"]
    west = zone["west"]
    east = zone["east"]
    center_lat = (north + south) / 2
    center_lon = (west + east) / 2

    # Radius endpoints use nautical miles. Query a generous circle, then
    # apply the exact configured bounding box locally.
    import math
    lat_nm = abs(north - south) * 60
    lon_nm = abs(east - west) * 60 * math.cos(math.radians(center_lat))
    radius_nm = max(10, int(math.ceil(math.hypot(lat_nm, lon_nm) / 2 + 5)))
    return center_lat, center_lon, radius_nm


class PositionClient:
    def __init__(self):
        self._opensky = OpenSkyClient()
        self.last_source = None

    def get_zone_states(self):
        _refresh_runtime_config()
        zone = _zone()
        print(
            "[Position] active box "
            f"N={zone['north']:.6f} S={zone['south']:.6f} "
            f"W={zone['west']:.6f} E={zone['east']:.6f}"
        )
        enabled = _enabled()
        all_states = []
        active_sources = []
        for source in _order():
            if enabled.get(source, True) is False:
                continue

            if source == "OpenSky":
                states = self._from_opensky()
            elif source == "ADSBLol":
                states = self._from_adsblol()
            elif source == "AirplanesLive":
                states = self._from_airplanes_live()
            elif source == "ADSBfi":
                states = self._from_adsbfi()
            else:
                continue

            if states:
                print(f"[Position] {source} {len(states)} aircraft")
                active_sources.append(source)
                for state in states:
                    state.setdefault("source", source)
                all_states.extend(states)
            else:
                print(f"[Position] {source} no usable aircraft")

        combined = self._validate_opensky_states(all_states)
        self.last_source = "+".join(active_sources) if active_sources else None
        if combined:
            summary = ", ".join(
                f"{state.get('callsign')}@"
                f"{state.get('latitude'):.4f},{state.get('longitude'):.4f}"
                f" age={state.get('position_age_seconds')}s"
                f" src={state.get('source', '?')}"
                for state in combined
            )
            print(f"[Position] accepted inside box: {summary}")
        return combined

    def _validate_opensky_states(self, states):
        by_icao = {}
        for state in states:
            icao24 = (state.get("icao24") or "").strip().lower()
            if icao24:
                by_icao.setdefault(icao24, []).append(state)

        accepted = []
        handled = set()
        for state in states:
            icao24 = (state.get("icao24") or "").strip().lower()
            key = icao24 or f"callsign:{state.get('callsign', '')}"
            if key in handled:
                continue
            handled.add(key)

            candidates = by_icao.get(icao24, [state]) if icao24 else [state]
            non_opensky = [
                candidate for candidate in candidates
                if candidate.get("source") != "OpenSky"
            ]
            opensky = [
                candidate for candidate in candidates
                if candidate.get("source") == "OpenSky"
            ]

            if non_opensky:
                callsigns = {
                    (candidate.get("callsign") or "").strip().upper()
                    for candidate in candidates
                    if candidate.get("callsign")
                }
                if len(callsigns) > 1:
                    print(
                        f"[Position] rejected identity conflict for hex={icao24}: "
                        f"{', '.join(sorted(callsigns))}"
                    )
                    continue
                accepted.append(_dedupe(non_opensky + opensky)[0])
                continue

            if not opensky:
                accepted.append(_dedupe(candidates)[0])
                continue

            opensky_state = _dedupe(opensky)[0]
            print(
                f"[Position] rejected unconfirmed OpenSky aircraft "
                f"{opensky_state.get('callsign', '?')} "
                f"hex={icao24 or '?'} "
                f"lat={opensky_state.get('latitude')} "
                f"lon={opensky_state.get('longitude')}"
            )
        return _dedupe(accepted)

    def find_callsign(self, callsign):
        return self._opensky.find_callsign(callsign)

    def get_flight_trail(self, icao24):
        return self._opensky.get_flight_trail(icao24)

    def _from_opensky(self):
        return [
            state for state in self._opensky.get_zone_states()
            if _within_zone(state)
        ]

    def _from_airplanes_live(self):
        print("[Position] AirplanesLive skipped: public v2 bbox endpoint unavailable")
        return []

    def _from_adsblol(self):
        lat, lon, radius_nm = _zone_center_radius_nm()
        url = f"https://api.adsb.lol/v2/lat/{lat:.6f}/lon/{lon:.6f}/dist/{radius_nm}"
        return self._from_adsb_style_url("ADSBLol", url)

    def _from_adsbfi(self):
        lat, lon, radius_nm = _zone_center_radius_nm()
        url = f"https://opendata.adsb.fi/api/v2/lat/{lat:.6f}/lon/{lon:.6f}/dist/{radius_nm}"
        return self._from_adsb_style_url("ADSBfi", url)

    def _from_adsb_style_url(self, source, url):
        try:
            timeout = (10, 15) if source == "ADSBLol" else (5, 10)
            try:
                resp = requests.get(
                    url,
                    headers={"Cache-Control": "no-cache", "Pragma": "no-cache"},
                    timeout=timeout,
                )
                resp.raise_for_status()
                data = resp.json()
            except Exception:
                if source != "ADSBLol":
                    raise
                data = self._from_adsblol_fallback(url)
        except Exception as e:
            print(f"[Position] {source} failed: {e}")
            return []

        states = []
        aircraft = data.get("ac") or data.get("aircraft") or []
        for ac in aircraft:
            state = _parse_adsb_aircraft(ac)
            if state and _within_zone(state):
                states.append(state)
        return _dedupe(states)

    def _from_adsblol_fallback(self, url):
        parsed = urlsplit(url)
        last_error = None
        for ip_address in ADSLOL_FALLBACK_IPS:
            pool = urllib3.HTTPSConnectionPool(
                ip_address,
                port=443,
                timeout=urllib3.Timeout(connect=10, read=15),
                retries=False,
                assert_hostname=parsed.hostname,
                server_hostname=parsed.hostname,
            )
            try:
                response = pool.request(
                    "GET",
                    parsed.path,
                    headers={
                        "Host": parsed.hostname,
                        "Cache-Control": "no-cache",
                        "Pragma": "no-cache",
                    },
                )
                if response.status >= 400:
                    raise RuntimeError(
                        f"fallback backend {ip_address} returned HTTP {response.status}"
                    )
                print(f"[Position] ADSBLol using fallback backend {ip_address}")
                return json.loads(response.data.decode("utf-8"))
            except Exception as e:
                last_error = e
            finally:
                pool.close()
        raise RuntimeError(f"all ADSBLol backends failed: {last_error}")
