#!/usr/bin/env python3
"""Read GNSS NMEA sentences and open a location in five map services.

Raspberry Pi / Linux, Python 3 standard library only.
Default serial port: /dev/ttyACM0 at 115200 baud.
"""

import argparse
import math
import os
import select
import sys
import termios
import time
import tty
import webbrowser
from urllib.parse import urlencode

DEFAULT_PORT = "/dev/ttyACM0"
DEFAULT_BAUD = 115200
EARTH_A = 6378245.0
EARTH_EE = 0.00669342162296594323
X_PI = math.pi * 3000.0 / 180.0


class NMEAReader:
    """Read newline-terminated NMEA data without third-party packages."""

    def __init__(self, device, baud):
        self.fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
        try:
            tty.setraw(self.fd)
            settings = termios.tcgetattr(self.fd)
            speed = getattr(termios, "B" + str(baud), None)
            if speed is None:
                raise ValueError("Unsupported baud rate: " + str(baud))
            settings[4] = speed
            settings[5] = speed
            settings[2] |= termios.CLOCAL | termios.CREAD
            settings[6][termios.VMIN] = 0
            settings[6][termios.VTIME] = 0
            termios.tcsetattr(self.fd, termios.TCSANOW, settings)
            termios.tcflush(self.fd, termios.TCIFLUSH)
        except Exception:
            os.close(self.fd)
            raise
        self.buffer = bytearray()

    def flush_input(self):
        termios.tcflush(self.fd, termios.TCIFLUSH)
        self.buffer.clear()

    def readline(self, timeout=1.0):
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            end = self.buffer.find(b"\n")
            if end >= 0:
                line = bytes(self.buffer[:end + 1])
                del self.buffer[:end + 1]
                decoded = line.decode("ascii", errors="ignore").strip()
                start = decoded.find("$")
                return decoded[start:] if start >= 0 else ""
            ready, _, _ = select.select([self.fd], [], [], max(0.0, deadline - time.monotonic()))
            if not ready:
                break
            try:
                chunk = os.read(self.fd, 4096)
            except BlockingIOError:
                continue
            if chunk:
                self.buffer.extend(chunk)
                if len(self.buffer) > 32768:
                    self.buffer.clear()
        return None

    def close(self):
        os.close(self.fd)


def checksum_ok(sentence):
    if not sentence.startswith("$"):
        return False
    if "*" not in sentence:
        return True
    data, checksum = sentence[1:].split("*", 1)
    try:
        expected = int(checksum[:2], 16)
    except ValueError:
        return False
    result = 0
    for character in data:
        result ^= ord(character)
    return result == expected


def rmc_time(sentence):
    parts = sentence.split(",")
    if len(parts) < 2 or not parts[0].endswith("RMC"):
        return None
    return parts[1]


def nmea_degrees(value, hemisphere, latitude):
    degree_width = 2 if latitude else 3
    if not value or len(value) <= degree_width:
        raise ValueError("Missing coordinate")
    degrees = int(value[:degree_width])
    minutes = float(value[degree_width:])
    if minutes < 0 or minutes >= 60:
        raise ValueError("Invalid NMEA minutes")
    if latitude and hemisphere not in ("N", "S"):
        raise ValueError("Invalid latitude hemisphere")
    if not latitude and hemisphere not in ("E", "W"):
        raise ValueError("Invalid longitude hemisphere")
    result = degrees + minutes / 60.0
    if hemisphere in ("S", "W"):
        result = -result
    if abs(result) > (90 if latitude else 180):
        raise ValueError("Coordinate out of range")
    return result


def parse_rmc(sentence):
    if not checksum_ok(sentence):
        return None
    fields = sentence.split("*")[0].split(",")
    if len(fields) < 10 or not fields[0].endswith("RMC"):
        return None
    if fields[2] != "A":
        return None
    try:
        lat = nmea_degrees(fields[3], fields[4], latitude=True)
        lon = nmea_degrees(fields[5], fields[6], latitude=False)
        return {"latitude": lat, "longitude": lon, "utc": fields[1] or "Unknown"}
    except (ValueError, IndexError):
        return None


def collect_one_cycle(reader, timeout=8.0):
    """Capture one RMC-to-RMC reporting cycle, or one timed NMEA burst."""
    reader.flush_input()
    lines = []
    first_rmc = None
    first_line_at = None
    end_at = time.monotonic() + timeout
    while time.monotonic() < end_at and len(lines) < 300:
        line = reader.readline(timeout=min(0.6, max(0.0, end_at - time.monotonic())))
        if not line or not line.startswith("$"):
            continue
        now = time.monotonic()
        if first_line_at is None:
            first_line_at = now
        if line.startswith(("$GPRMC", "$GNRMC", "$GLRMC", "$GARMC", "$BDRMC", "$GBRMC")):
            this_time = rmc_time(line)
            if first_rmc is not None and this_time and this_time != first_rmc:
                break
            if first_rmc is None:
                first_rmc = this_time
        lines.append(line)
        if first_line_at is not None and now - first_line_at >= 3.0:
            break
    return lines


def fresh_fix(reader, timeout=10.0):
    """Require a new, valid RMC reading; never use a stale cached fix."""
    reader.flush_input()
    finish = time.monotonic() + timeout
    while time.monotonic() < finish:
        line = reader.readline(timeout=min(1.0, max(0.0, finish - time.monotonic())))
        if line:
            fix = parse_rmc(line)
            if fix:
                return fix
    return None


def outside_china(lat, lon):
    # Approximate region boundary used by the standard offline transformation.
    return lon < 72.004 or lon > 137.8347 or lat < 0.8293 or lat > 55.8271


def transform_lat(x, y):
    value = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
    value += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
    value += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
    value += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
    return value


def transform_lon(x, y):
    value = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
    value += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
    value += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
    value += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
    return value


def wgs84_to_gcj02(lat, lon):
    if outside_china(lat, lon):
        return lat, lon
    d_lat = transform_lat(lon - 105.0, lat - 35.0)
    d_lon = transform_lon(lon - 105.0, lat - 35.0)
    rad_lat = lat / 180.0 * math.pi
    magic = math.sin(rad_lat)
    magic = 1.0 - EARTH_EE * magic * magic
    sqrt_magic = math.sqrt(magic)
    d_lat = (d_lat * 180.0) / ((EARTH_A * (1.0 - EARTH_EE)) / (magic * sqrt_magic) * math.pi)
    d_lon = (d_lon * 180.0) / (EARTH_A / sqrt_magic * math.cos(rad_lat) * math.pi)
    return lat + d_lat, lon + d_lon


def gcj02_to_bd09(lat, lon):
    z = math.sqrt(lon * lon + lat * lat) + 0.00002 * math.sin(lat * X_PI)
    theta = math.atan2(lat, lon) + 0.000003 * math.cos(lon * X_PI)
    return z * math.sin(theta) + 0.006, z * math.cos(theta) + 0.0065


def url_with_params(base, params):
    return base + "?" + urlencode(params)


def build_map(choice, lat, lon, referer, google_coordinates="auto", bing_coordinates="auto"):
    """Build a map link without changing the GNSS WGS84 source position.

    Chinese Google/Bing road map alignment is an empirical workaround, not a
    published guarantee about their web URL coordinate reference systems.
    Users can force WGS84 for satellite imagery and other map layers.
    """
    china = not outside_china(lat, lon)
    if choice in ("2", "3"):
        mode = google_coordinates if choice == "2" else bing_coordinates
        road_layer_offset = mode == "gcj02" or (mode == "auto" and china)
    else:
        road_layer_offset = False

    if choice in ("1", "5") or road_layer_offset:
        map_lat, map_lon = wgs84_to_gcj02(lat, lon)
        coordinate_name = "GCJ-02"
    elif choice == "4":
        gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
        if china:
            map_lat, map_lon = gcj02_to_bd09(gcj_lat, gcj_lon)
            coordinate_name = "BD-09"
        else:
            # Avoid applying a Chinese datum shift outside mainland China.
            # The BD-09 URL is meant primarily for Baidu's mainland map.
            map_lat, map_lon = lat, lon
            coordinate_name = "WGS84 (outside mainland China)"
    else:
        map_lat, map_lon = lat, lon
        coordinate_name = "WGS84"

    lat_text = f"{map_lat:.8f}"
    lon_text = f"{map_lon:.8f}"
    if choice == "1":
        name = "Amap"
        url = url_with_params("https://uri.amap.com/marker", {
            "position": lon_text + "," + lat_text,
            "name": "GNSS Position",
            "coordinate": "gaode", "callnative": "0", "src": "gnss_multi_map"
        })
    elif choice == "2":
        name = "Google Maps"
        # Google Maps URLs accept lat,lon. hl=en requests English UI.
        url = url_with_params("https://www.google.com/maps/search/", {
            "api": "1", "query": lat_text + "," + lon_text, "hl": "en"
        })
    elif choice == "3":
        name = "Bing Maps"
        # The Bing link accepts lat~lon for center and lat_lon for the marker.
        url = url_with_params("https://www.bing.com/maps/default.aspx", {
            "cp": lat_text + "~" + lon_text,
            "lvl": "17", "style": "r",
            "sp": "point." + lat_text + "_" + lon_text + "_GNSS Position",
            "setLang": "en", "setMkt": "en-US"
        })
    elif choice == "4":
        name = "Baidu Maps"
        url = url_with_params("https://api.map.baidu.com/marker", {
            "location": lat_text + "," + lon_text,
            "title": "GNSS Position", "content": "GNSS Position",
            "output": "html", "coord_type": "bd09ll" if china else "wgs84",
            "src": "webapp.gnss.multi_map"
        })
    elif choice == "5":
        name = "Tencent Maps"
        params = {
            "marker": "coord:" + lat_text + "," + lon_text + ";title:GNSS Position",
            "coord_type": "2"  # Tencent's URI API: 2 = Tencent / GCJ-02
        }
        if referer:
            params["referer"] = referer
        url = url_with_params("https://apis.map.qq.com/uri/v1/marker", params)
    else:
        raise ValueError("Unknown map selection")
    return name, coordinate_name, map_lat, map_lon, url


def main():
    parser = argparse.ArgumentParser(description="L76X GNSS five-map locator (standard library only)")
    parser.add_argument("--port", default=DEFAULT_PORT, help="Serial port (default: /dev/ttyACM0)")
    parser.add_argument("--baud", type=int, default=DEFAULT_BAUD, help="Baud rate (default: 115200)")
    parser.add_argument("--no-open", action="store_true", help="Print URLs without launching a browser")
    parser.add_argument("--google-coordinates", choices=("auto", "wgs84", "gcj02"),
                        default="auto", help="Google mainland China road alignment (default: auto)")
    parser.add_argument("--bing-coordinates", choices=("auto", "wgs84", "gcj02"),
                        default="auto", help="Bing road-map coordinate mode (default: auto)")
    parser.add_argument("--tencent-referer", default=os.environ.get("TENCENT_REFERER", ""),
                        help="Registered Tencent Maps URI referer")
    args = parser.parse_args()

    try:
        reader = NMEAReader(args.port, args.baud)
    except (OSError, ValueError, termios.error) as exc:
        print("Cannot open serial port: " + str(exc))
        return 1

    try:
        print("GNSS Multi-Map Locator")
        print("Port: " + args.port)
        print("Baud rate: " + str(args.baud))
        print("\nReading one complete NMEA reporting cycle...")
        lines = collect_one_cycle(reader)
        print("\n========== NMEA START ==========")
        if lines:
            for line in lines:
                print(line)
        else:
            print("No NMEA data received. Check serial wiring and baud rate.")
        print("=========== NMEA END ===========")

        while True:
            print("\n========== MAP MENU ==========")
            print("  1. Amap")
            print("  2. Google Maps")
            print("  3. Bing Maps")
            print("  4. Baidu Maps")
            print("  5. Tencent Maps")
            print("  6. Print one NMEA cycle again")
            print("  0. Exit")
            choice = input("Select an option: ").strip()
            if choice == "0":
                break
            if choice == "6":
                lines = collect_one_cycle(reader)
                print("\n========== NMEA START ==========")
                for line in lines:
                    print(line)
                if not lines:
                    print("No NMEA data received.")
                print("=========== NMEA END ===========")
                continue
            if choice not in ("1", "2", "3", "4", "5"):
                print("Invalid selection.")
                continue

            print("Reading a new valid RMC position...")
            fix = fresh_fix(reader)
            if fix is None:
                print("No fresh valid GNSS fix within 10 seconds. Check sky visibility and NMEA RMC output.")
                continue
            lat, lon = fix["latitude"], fix["longitude"]
            name, system, converted_lat, converted_lon, url = build_map(
                choice, lat, lon, args.tencent_referer, args.google_coordinates, args.bing_coordinates
            )
            print("\nMap: " + name)
            print("UTC: " + fix["utc"])
            print("Original GNSS coordinates (WGS84):")
            print("  Latitude:  {:.8f}".format(lat))
            print("  Longitude: {:.8f}".format(lon))
            print("Map coordinates (" + system + "):")
            print("  Latitude:  {:.8f}".format(converted_lat))
            print("  Longitude: {:.8f}".format(converted_lon))
            print("URL:\n" + url)
            if choice in ("2", "3") and not outside_china(lat, lon):
                mode = args.google_coordinates if choice == "2" else args.bing_coordinates
                if mode == "auto":
                    option = "--google-coordinates" if choice == "2" else "--bing-coordinates"
                    print("Note: A GCJ-02 mainland road-map alignment workaround is active.")
                    print("Use " + option + " wgs84 to compare with GPS/satellite imagery.")
            if choice == "5" and not args.tencent_referer:
                print("Note: Tencent Maps may reject this URI without a registered referer.")
                print("Set --tencent-referer YOUR_REFERER if required by Tencent Maps.")
            if not args.no_open:
                if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
                    if not webbrowser.open(url):
                        print("Browser launch failed. Copy the URL above into a browser.")
                else:
                    print("No desktop session detected. Open the URL on another device.")
    except (KeyboardInterrupt, EOFError):
        print("\nExiting.")
    finally:
        reader.close()
        print("Serial port closed.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
