#!/usr/bin/env python3
import json
import math
import signal
import subprocess
import sys
import time
from typing import Any, Dict, List, Optional


# offset_ns is computed as:

# (clock_sec * 1e9 + clock_nsec) - (real_sec * 1e9 + real_nsec)


GPSPIPE_CMD = ["gpspipe", "-w", "--json"]
HOST_TAG = "ntprana"


running = True


def handle_signal(signum, frame):
    global running
    running = False


signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)


def esc_tag(value: Any) -> str:
    s = str(value)
    return s.replace("\\", "\\\\").replace(" ", r"\ ").replace(",", r"\,").replace("=", r"\=")


def esc_field_str(value: Any) -> str:
    s = str(value)
    s = s.replace("\\", "\\\\").replace('"', r"\"")
    return f'"{s}"'


def is_num(value: Any) -> bool:
    return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)


def bool_to_field(value: bool) -> str:
    return "true" if value else "false"


def build_line(measurement: str, tags: Dict[str, Any], fields: Dict[str, Any], ts_ns: Optional[int] = None) -> Optional[str]:
    clean_fields = []
    for key, value in fields.items():
        if value is None:
            continue
        if isinstance(value, bool):
            clean_fields.append(f"{key}={bool_to_field(value)}")
        elif isinstance(value, int) and not isinstance(value, bool):
            clean_fields.append(f"{key}={value}i")
        elif is_num(value):
            clean_fields.append(f"{key}={value}")
        else:
            clean_fields.append(f"{key}={esc_field_str(value)}")

    if not clean_fields:
        return None

    tag_str = ",".join(f"{esc_tag(k)}={esc_tag(v)}" for k, v in tags.items() if v is not None)
    line = measurement
    if tag_str:
        line += "," + tag_str
    line += " " + ",".join(clean_fields)
    if ts_ns is not None:
        line += f" {ts_ns}"
    return line


def gps_constellation_name(gnssid: Optional[int]) -> str:
    mapping = {
        0: "gps",
        1: "sbas",
        2: "galileo",
        3: "beidou",
        4: "imes",
        5: "qzss",
        6: "glonass",
    }
    return mapping.get(gnssid, "unknown")


def summarize_satellites(satellites: List[Dict[str, Any]]) -> Dict[str, Any]:
    out: Dict[str, Any] = {
        "gps_visible": 0,
        "gps_used": 0,
        "sbas_visible": 0,
        "sbas_used": 0,
        "galileo_visible": 0,
        "galileo_used": 0,
        "beidou_visible": 0,
        "beidou_used": 0,
        "qzss_visible": 0,
        "qzss_used": 0,
        "glonass_visible": 0,
        "glonass_used": 0,
        "unknown_visible": 0,
        "unknown_used": 0,
        "used_signal_sum": 0.0,
        "used_signal_count": 0,
        "visible_signal_sum": 0.0,
        "visible_signal_count": 0,
        "max_signal": 0.0,
    }

    for sat in satellites:
        constellation = gps_constellation_name(sat.get("gnssid"))
        ss = sat.get("ss", 0.0)
        used = bool(sat.get("used", False))

        vis_key = f"{constellation}_visible"
        used_key = f"{constellation}_used"

        if vis_key not in out:
            vis_key = "unknown_visible"
            used_key = "unknown_used"

        out[vis_key] += 1
        if used:
            out[used_key] += 1

        if is_num(ss):
            out["visible_signal_sum"] += float(ss)
            out["visible_signal_count"] += 1
            out["max_signal"] = max(out["max_signal"], float(ss))
            if used:
                out["used_signal_sum"] += float(ss)
                out["used_signal_count"] += 1

    out["avg_signal_visible"] = (
        out["visible_signal_sum"] / out["visible_signal_count"] if out["visible_signal_count"] else None
    )
    out["avg_signal_used"] = (
        out["used_signal_sum"] / out["used_signal_count"] if out["used_signal_count"] else None
    )

    # cleanup helper sums/counters from final output
    del out["visible_signal_sum"]
    del out["visible_signal_count"]
    del out["used_signal_sum"]
    del out["used_signal_count"]

    return out


def pps_offset_ns(pps: Dict[str, Any]) -> Optional[int]:
    real_sec = pps.get("real_sec")
    real_nsec = pps.get("real_nsec")
    clock_sec = pps.get("clock_sec")
    clock_nsec = pps.get("clock_nsec")

    if not all(isinstance(v, int) for v in [real_sec, real_nsec, clock_sec, clock_nsec]):
        return None

    real_total = real_sec * 1_000_000_000 + real_nsec
    clock_total = clock_sec * 1_000_000_000 + clock_nsec
    return clock_total - real_total


def main():
    global running

    proc = subprocess.Popen(
        GPSPIPE_CMD,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1,
    )

    latest_tpv: Optional[Dict[str, Any]] = None
    latest_sky: Optional[Dict[str, Any]] = None
    latest_pps: Optional[Dict[str, Any]] = None
    last_emit_sec: Optional[int] = None
    # NGL at some point I just put cloudee to do it bc I've wrote simmilar code before many times
    try:
        assert proc.stdout is not None
        while running:
            line = proc.stdout.readline()
            if not line:
                if proc.poll() is not None:
                    break
                time.sleep(0.05)
                continue

            line = line.strip()
            if not line or not line.startswith("{"):
                continue

            try:
                msg = json.loads(line)
            except json.JSONDecodeError:
                continue

            cls = msg.get("class")

            if cls == "TPV":
                latest_tpv = msg
            elif cls == "SKY":
                if latest_sky is None:
                    latest_sky = msg
                else:
                    merged = latest_sky.copy()
                    merged.update(msg)
            
                    # Keep the richer satellites list if the new SKY message doesn't include it
                    if "satellites" not in msg and "satellites" in latest_sky:
                        merged["satellites"] = latest_sky["satellites"]
            
                    # Keep nSat/uSat if the new SKY message doesn't include them
                    if "nSat" not in msg and "nSat" in latest_sky:
                        merged["nSat"] = latest_sky["nSat"]
            
                    if "uSat" not in msg and "uSat" in latest_sky:
                        merged["uSat"] = latest_sky["uSat"]
            
                    latest_sky = merged
            elif cls == "PPS":
                latest_pps = msg
            else:
                continue

            now_sec = int(time.time())
            if last_emit_sec == now_sec:
                continue

            lines_out = []
            ts_ns = time.time_ns()

            common_tags = {
                "host": HOST_TAG,
                "device": (latest_tpv or latest_sky or latest_pps or {}).get("device", "unknown"),
            }

            if latest_tpv:
                fix_fields = {
                    "mode": latest_tpv.get("mode"),
                    "lat": latest_tpv.get("lat"),
                    "lon": latest_tpv.get("lon"),
                    "alt": latest_tpv.get("alt"),
                    "alt_msl": latest_tpv.get("altMSL"),
                    "alt_hae": latest_tpv.get("altHAE"),
                    "speed": latest_tpv.get("speed"),
                    "climb": latest_tpv.get("climb"),
                    "track": latest_tpv.get("track"),
                    "magtrack": latest_tpv.get("magtrack"),
                    "magvar": latest_tpv.get("magvar"),
                    "ept": latest_tpv.get("ept"),
                    "epx": latest_tpv.get("epx"),
                    "epy": latest_tpv.get("epy"),
                    "epv": latest_tpv.get("epv"),
                    "eph": latest_tpv.get("eph"),
                    "sep": latest_tpv.get("sep"),
                    "ecefpAcc": latest_tpv.get("ecefpAcc"),
                    "ecefvAcc": latest_tpv.get("ecefvAcc"),
                    "leapseconds": latest_tpv.get("leapseconds"),
                    "fix_valid": latest_tpv.get("mode", 0) >= 2,
                    "fix_3d": latest_tpv.get("mode", 0) == 3,
                }
                line_fix = build_line("gps_fix", common_tags, fix_fields, ts_ns)
                if line_fix:
                    lines_out.append(line_fix)

            if latest_sky:
                satellites = latest_sky.get("satellites", [])
                sat_summary = summarize_satellites(satellites) if isinstance(satellites, list) else {}

                n_sat = latest_sky.get("nSat")
                u_sat = latest_sky.get("uSat")
                sat_used_ratio = None
                if isinstance(n_sat, int) and n_sat > 0 and isinstance(u_sat, int):
                    sat_used_ratio = u_sat / n_sat

                sky_fields = {
                    "nSat": n_sat,
                    "uSat": u_sat,
                    "sat_used_ratio": sat_used_ratio,
                    "gdop": latest_sky.get("gdop"),
                    "hdop": latest_sky.get("hdop"),
                    "pdop": latest_sky.get("pdop"),
                    "tdop": latest_sky.get("tdop"),
                    "vdop": latest_sky.get("vdop"),
                    "xdop": latest_sky.get("xdop"),
                    "ydop": latest_sky.get("ydop"),
                    **sat_summary,
                }
                line_sky = build_line("gps_sky", common_tags, sky_fields, ts_ns)
                if line_sky:
                    lines_out.append(line_sky)

            if latest_pps:
                offset_ns = pps_offset_ns(latest_pps)
                pps_fields = {
                    "offset_ns": offset_ns,
                    "precision_exp": latest_pps.get("precision"),
                    "present": True,
                }
                line_pps = build_line(
                    "gps_pps",
                    {
                        "host": HOST_TAG,
                        "device": latest_pps.get("device", "/dev/pps0"),
                    },
                    pps_fields,
                    ts_ns,
                )
                if line_pps:
                    lines_out.append(line_pps)

            if lines_out:
                sys.stdout.write("\n".join(lines_out) + "\n")
                sys.stdout.flush()
                last_emit_sec = now_sec

    finally:
        if proc.poll() is None:
            proc.terminate()
            try:
                proc.wait(timeout=2)
            except subprocess.TimeoutExpired:
                proc.kill()


if __name__ == "__main__":
    main()
