#!/usr/bin/env python3
"""NTRIP 캐스터 조사/검증 도구 — 마운트포인트가 '진짜 단일기준국'인지 판별한다.

VRS(가상 기지국)와 단일기준국(실제 상시관측소)은 같은 RTCM3 스트림으로 오지만
메시지 구성이 다르다. 이 도구는 스트림을 직접 뜯어서 구분한다.

  table  캐스터 소스테이블 출력 (인증 불필요)
  near   내 좌표에서 가까운 기준국 마운트포인트 정렬
  sniff  마운트포인트에 접속해 RTCM 메시지 종류/기지국 좌표/베이스라인을 관찰
         → 기지국 좌표가 시간에 따라 움직이면 VRS, 고정이면 실제 기지국

사용 예:
  python3 ntrip_probe.py table gnssdata.or.kr 2101
  python3 ntrip_probe.py near  gnssdata.or.kr 2101 37.5665 126.9780
  python3 ntrip_probe.py sniff RTS1.ngii.go.kr 2101 VRS-RTCM34 \
      --user ID --pass PW --gga-from-latlon 37.5665 126.9780 --secs 60
"""

import argparse
import base64
import math
import socket
import sys
import time
from collections import Counter

# ── RTCM3 프레이밍 ──────────────────────────────────────────────────────────
# 0xD3 | 6bit reserved + 10bit length | payload | 3byte CRC-24Q

_CRC24_TAB = []


def _init_crc24():
    poly = 0x1864CFB
    for i in range(256):
        crc = i << 16
        for _ in range(8):
            crc <<= 1
            if crc & 0x1000000:
                crc ^= poly
        _CRC24_TAB.append(crc & 0xFFFFFF)


_init_crc24()


def crc24q(data: bytes) -> int:
    crc = 0
    for b in data:
        crc = ((crc << 8) & 0xFFFFFF) ^ _CRC24_TAB[((crc >> 16) ^ b) & 0xFF]
    return crc


class Bits:
    """MSB-first 비트 리더 (RTCM DF 필드용)."""

    def __init__(self, buf: bytes):
        self.buf = buf
        self.pos = 0

    def u(self, n: int) -> int:
        v = 0
        for _ in range(n):
            byte = self.buf[self.pos >> 3]
            v = (v << 1) | ((byte >> (7 - (self.pos & 7))) & 1)
            self.pos += 1
        return v

    def s(self, n: int) -> int:
        v = self.u(n)
        return v - (1 << n) if v & (1 << (n - 1)) else v


def parse_1005_1006(payload: bytes):
    """1005/1006 → (station_id, X, Y, Z, antenna_height|None). 단위 m."""
    b = Bits(payload)
    msg = b.u(12)
    if msg not in (1005, 1006):
        return None
    sid = b.u(12)
    b.u(6)                      # ITRF realization year
    b.u(1); b.u(1); b.u(1)      # GPS / GLONASS / Galileo indicator
    b.u(1)                      # reference station indicator
    x = b.s(38) * 1e-4
    b.u(1); b.u(1)              # single receiver osc / reserved
    y = b.s(38) * 1e-4
    b.u(2)                      # quarter cycle indicator
    z = b.s(38) * 1e-4
    h = b.u(16) * 1e-4 if msg == 1006 else None
    return sid, x, y, z, h


def ecef_to_lla(x, y, z):
    a, f = 6378137.0, 1 / 298.257223563
    e2 = f * (2 - f)
    lon = math.atan2(y, x)
    p = math.hypot(x, y)
    lat = math.atan2(z, p * (1 - e2))
    for _ in range(10):
        n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
        alt = p / math.cos(lat) - n
        lat = math.atan2(z, p * (1 - e2 * n / (n + alt)))
    n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
    return math.degrees(lat), math.degrees(lon), p / math.cos(lat) - n


def haversine_km(lat1, lon1, lat2, lon2):
    r = 6371.0
    dlat = math.radians(lat2 - lat1)
    dlon = math.radians(lon2 - lon1)
    h = (math.sin(dlat / 2) ** 2
         + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2)
    return 2 * r * math.asin(math.sqrt(h))


# 네트워크 RTK(=VRS/FKP/MAC) 임을 드러내는 메시지들
NETWORK_MSGS = {
    1014: "Network Auxiliary Station Data (MAC)",
    1015: "GPS Ionospheric Correction Differences (MAC)",
    1016: "GPS Geometric Correction Differences (MAC)",
    1017: "GPS Combined Correction Differences (MAC)",
    1030: "GPS Network RTK Residual",
    1031: "GLONASS Network RTK Residual",
    1032: "Physical Reference Station Position (VRS)",
    1034: "GPS Network FKP Gradient",
    1035: "GLONASS Network FKP Gradient",
}


# ── NTRIP ───────────────────────────────────────────────────────────────────
def ntrip_open(host, port, mount=None, user="", pw="", timeout=15):
    s = socket.create_connection((host, port), timeout=timeout)
    path = "/" + (mount or "")
    req = f"GET {path} HTTP/1.0\r\nUser-Agent: NTRIP ntrip_probe/1.0\r\nAccept: */*\r\n"
    if user or pw:
        auth = base64.b64encode(f"{user}:{pw}".encode()).decode()
        req += f"Authorization: Basic {auth}\r\n"
    req += "Connection: close\r\n\r\n"
    s.sendall(req.encode())
    return s


def read_header(s):
    """응답 헤더를 소진하고 (상태줄, 남은 바이트) 반환."""
    buf = b""
    while b"\r\n\r\n" not in buf and b"\n\n" not in buf:
        chunk = s.recv(4096)
        if not chunk:
            break
        buf += chunk
        if buf.startswith(b"ICY 200 OK") and len(buf) > 12:  # NTRIP1 짧은 응답
            break
        if len(buf) > 65536:
            break
    sep = b"\r\n\r\n" if b"\r\n\r\n" in buf else b"\n\n"
    head, _, rest = buf.partition(sep)
    status = head.split(b"\r\n")[0].decode("latin1", "replace")
    return status, head, rest


def fetch_sourcetable(host, port):
    s = ntrip_open(host, port)
    data = b""
    try:
        while True:
            chunk = s.recv(8192)
            if not chunk:
                break
            data += chunk
    finally:
        s.close()
    return data.decode("latin1", "replace")


def parse_str_rows(text, host=""):
    # 캐스터가 짧은 간격의 재요청을 조용히 끊는 일이 있다 — 빈 결과를 정상으로 오인하면 안 된다.
    if "STR;" not in text:
        print(f"# ⚠️ 소스테이블 응답에 STR 항목이 없음 ({len(text)} bytes)"
              + (f" — {host} 재시도해볼 것" if host else ""), file=sys.stderr)
        if text[:200].strip():
            print("# 응답 앞부분: " + repr(text[:200]), file=sys.stderr)
    rows = []
    for line in text.splitlines():
        if not line.startswith("STR;"):
            continue
        f = line.split(";")
        if len(f) < 13:
            continue
        try:
            lat, lon = float(f[9]), float(f[10])
        except ValueError:
            lat = lon = None
        rows.append({
            "mount": f[1], "format": f[3], "details": f[4], "nav": f[6],
            "network": f[7], "lat": lat, "lon": lon,
            "nmea": f[11], "solution": f[12],
            "generator": f[13] if len(f) > 13 else "",
            "auth": f[15] if len(f) > 15 else "",
        })
    return rows


def nmea_gga(lat, lon, alt=50.0):
    def dm(v, deg_w):
        d = int(abs(v))
        m = (abs(v) - d) * 60
        return f"{d:0{deg_w}d}{m:09.6f}"
    t = time.gmtime()
    body = (f"GPGGA,{t.tm_hour:02d}{t.tm_min:02d}{t.tm_sec:02d}.00,"
            f"{dm(lat,2)},{'N' if lat>=0 else 'S'},"
            f"{dm(lon,3)},{'E' if lon>=0 else 'W'},"
            f"1,10,1.0,{alt:.1f},M,20.0,M,,")
    ck = 0
    for c in body:
        ck ^= ord(c)
    return f"${body}*{ck:02X}\r\n".encode()


# ── 서브커맨드 ──────────────────────────────────────────────────────────────
def cmd_table(a):
    text = fetch_sourcetable(a.host, a.port)
    rows = parse_str_rows(text, a.host)
    print(f"# {a.host}:{a.port} — STR {len(rows)}개")
    print(f"{'MOUNTPOINT':<18} {'FORMAT':<9} {'NAV':<34} {'NET':<12} {'NMEA':<5} {'SOL':<4} {'종류'}")
    for r in rows:
        if a.filter and a.filter.upper() not in r["mount"].upper():
            continue
        kind = "네트워크(VRS/FKP/MAC)" if r["solution"] == "1" else "단일기준국"
        print(f"{r['mount']:<18} {r['format']:<9} {r['nav'][:34]:<34} "
              f"{r['network'][:12]:<12} {r['nmea']:<5} {r['solution']:<4} {kind}")
    net = sum(1 for r in rows if r["solution"] == "1")
    print(f"\n요약: 단일기준국 {len(rows)-net}개 / 네트워크 {net}개")
    print("  · SOL=0 → 실제 기준국 1곳의 관측치(단일기준국)")
    print("  · SOL=1 → 망 보간 해(VRS/FKP/MAC). NMEA=1 이면 GGA 업링크 필요")


def cmd_near(a):
    rows = parse_str_rows(fetch_sourcetable(a.host, a.port), a.host)
    cand = []
    for r in rows:
        if r["lat"] is None or (r["lat"] == 0 and r["lon"] == 0):
            continue
        if a.format and a.format.upper() not in r["format"].upper().replace(" ", ""):
            continue
        cand.append((haversine_km(a.lat, a.lon, r["lat"], r["lon"]), r))
    cand.sort(key=lambda t: t[0])
    print(f"# 기준 위치 {a.lat:.5f}, {a.lon:.5f} — 가까운 순 {a.n}개"
          + (f" (포맷 필터: {a.format})" if a.format else ""))
    print(f"{'거리km':>8}  {'MOUNTPOINT':<18} {'FORMAT':<9} {'NAV':<34} {'SOL'}")
    for d, r in cand[:a.n]:
        print(f"{d:8.1f}  {r['mount']:<18} {r['format']:<9} {r['nav'][:34]:<34} {r['solution']}")
    print("\n주의: 소스테이블 좌표는 반올림/오기입이 흔하다(0.1도 = 약 11km).")
    print("      최종 확인은 sniff 로 1005/1006 기지국 실좌표를 봐야 한다.")


def cmd_sniff(a):
    s = ntrip_open(a.host, a.port, a.mount, a.user, getattr(a, "pw"))
    status, head, rest = read_header(s)
    print(f"# 접속: {a.host}:{a.port}/{a.mount}")
    print(f"# 응답: {status}")
    if "200" not in status:
        print(head.decode("latin1", "replace"))
        return 1

    gga = None
    if a.gga_from_latlon:
        gga = nmea_gga(a.gga_from_latlon[0], a.gga_from_latlon[1])
        s.sendall(gga)
        print(f"# GGA 업링크: {gga.decode().strip()}")

    s.settimeout(5)
    buf = bytearray(rest)
    types = Counter()
    bases = {}          # station_id -> 마지막 좌표
    base_moves = []     # (t, sid, 이동거리 m)
    total = 0
    t0 = time.time()
    last_gga = t0

    while time.time() - t0 < a.secs:
        try:
            chunk = s.recv(4096)
        except socket.timeout:
            print("  (수신 없음 5초)")
            continue
        if not chunk:
            print("# 스트림 종료")
            break
        total += len(chunk)
        buf += chunk

        i = 0
        while True:
            j = buf.find(b"\xd3", i)
            if j < 0 or len(buf) - j < 3:
                break
            # 길이 상위 바이트의 reserved 6bit 는 항상 0. 이걸 안 보면 페이로드 안의
            # 우연한 0xD3 을 프레임 시작으로 착각해 최대 1023B 동안 파서가 멈춘다.
            if buf[j + 1] & 0xFC:
                i = j + 1
                continue
            length = ((buf[j + 1] & 0x03) << 8) | buf[j + 2]
            frame_len = 3 + length + 3
            if len(buf) - j < frame_len:
                break
            frame = bytes(buf[j:j + frame_len])
            got = int.from_bytes(frame[-3:], "big")
            if crc24q(frame[:-3]) != got:
                i = j + 1                      # 오정렬 → 다음 0xD3 부터
                continue
            payload = frame[3:3 + length]
            if length >= 2:
                mt = (payload[0] << 4) | (payload[1] >> 4)
                types[mt] += 1
                if mt in (1005, 1006):
                    p = parse_1005_1006(payload)
                    if p:
                        sid, x, y, z, h = p
                        prev = bases.get(sid)
                        if prev and (abs(prev[0] - x) > 0.5 or abs(prev[1] - y) > 0.5
                                     or abs(prev[2] - z) > 0.5):
                            d = math.dist(prev[:3], (x, y, z))
                            base_moves.append((time.time() - t0, sid, d))
                            print(f"  ⚠️ 기지국 좌표 이동: id={sid} {d:.1f} m  (t+{time.time()-t0:.0f}s)")
                        bases[sid] = (x, y, z, h)
            i = j + frame_len
        del buf[:i]

        if gga and time.time() - last_gga > 10:
            s.sendall(gga)
            last_gga = time.time()

    s.close()

    dur = time.time() - t0
    print(f"\n## 결과 ({dur:.0f}초, {total} bytes, {total/max(dur,1):.0f} B/s)")
    print("\n### RTCM 메시지 종류")
    for mt, c in sorted(types.items()):
        tag = f"  ← {NETWORK_MSGS[mt]}" if mt in NETWORK_MSGS else ""
        print(f"  {mt:<5} x{c:<6}{tag}")

    print("\n### 기지국(1005/1006) 좌표")
    if not bases:
        print("  없음 — 1005/1006 이 안 왔다. 관측 시간을 늘려보라(권장 60초 이상).")
    for sid, (x, y, z, h) in bases.items():
        lat, lon, alt = ecef_to_lla(x, y, z)
        line = f"  id={sid}  {lat:.7f}, {lon:.7f}, {alt:.2f}m"
        if h is not None:
            line += f" (안테나고 {h:.3f}m)"
        print(line)
        if a.gga_from_latlon:
            d = haversine_km(a.gga_from_latlon[0], a.gga_from_latlon[1], lat, lon)
            print(f"        베이스라인 {d:.2f} km")

    print("\n### 판정")
    net_found = sorted(set(types) & set(NETWORK_MSGS))
    if net_found:
        print(f"  ❌ 네트워크 RTK 스트림 — 네트워크 전용 메시지 {net_found} 존재")
    elif base_moves:
        print(f"  ❌ 기지국 좌표가 {len(base_moves)}회 이동 — VRS(가상 기지국)")
    elif bases:
        print("  ✅ 단일기준국으로 보임 — 네트워크 메시지 없음 + 기지국 좌표 고정")
    else:
        print("  ? 판정 불가 — 1005/1006 미수신")
    print("\n  ※ VRS 는 GGA 를 보낸 위치 근처에 가상 기지국을 만든다. 정지 상태로 짧게 보면")
    print("     좌표가 고정으로 보일 수 있으므로, 네트워크 메시지 유무를 1차 근거로 삼을 것.")
    return 0


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("table", help="소스테이블 출력")
    p.add_argument("host"); p.add_argument("port", type=int)
    p.add_argument("--filter", help="마운트포인트 이름 부분일치 필터")
    p.set_defaults(func=cmd_table)

    p = sub.add_parser("near", help="내 위치에서 가까운 기준국 정렬")
    p.add_argument("host"); p.add_argument("port", type=int)
    p.add_argument("lat", type=float); p.add_argument("lon", type=float)
    p.add_argument("-n", type=int, default=10)
    p.add_argument("--format", help="포맷 필터 (예: RTCM3.2)")
    p.set_defaults(func=cmd_near)

    p = sub.add_parser("sniff", help="스트림을 뜯어 VRS/단일기준국 판정")
    p.add_argument("host"); p.add_argument("port", type=int); p.add_argument("mount")
    p.add_argument("--user", default=""); p.add_argument("--pass", dest="pw", default="")
    p.add_argument("--gga-from-latlon", nargs=2, type=float, metavar=("LAT", "LON"),
                   help="GGA 업링크 (VRS/nmea=1 마운트포인트에 필요)")
    p.add_argument("--secs", type=int, default=60)
    p.set_defaults(func=cmd_sniff)

    a = ap.parse_args()
    sys.exit(a.func(a) or 0)


if __name__ == "__main__":
    main()
