#!/usr/bin/env python3
"""IP geolocation, cached with a TTL.

Discovers the public IP by tracerouting to 1.1.1.1 and taking the first
globally-routable hop (the ISP egress nearest the user), then resolves that IP to a
location through a public geolocation API. Falls back to locating this host's own
public IP when traceroute is unavailable or yields no public hop.

Prints JSON: {lat, lon, city, country, ip, source}. Diagnostics go to stderr,
non-zero exit on total failure. Used by services/location.py (and runnable
standalone for testing).
"""

from __future__ import annotations

import ipaddress
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu" / "location.json"
TTL = 1800  # seconds

TARGET = "1.1.1.1"
_IPV4 = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b")

# Geolocation providers. Each builds a URL for a given IP; an empty IP asks the
# provider to resolve the caller's own public address (the self-IP fallback).
PROVIDERS = [
    ("ip-api",
     lambda ip: f"http://ip-api.com/json/{ip}?fields=lat,lon,city,country,query",
     lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"),
                "country": d.get("country"), "ip": d.get("query")}),
    ("ipapi.co",
     lambda ip: f"https://ipapi.co/{ip}/json/" if ip else "https://ipapi.co/json/",
     lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"),
                "country": d.get("country_name"), "ip": d.get("ip")}),
    ("ipinfo",
     lambda ip: f"https://ipinfo.io/{ip}/json" if ip else "https://ipinfo.io/json",
     lambda d: {"lat": float(d["loc"].split(",")[0]), "lon": float(d["loc"].split(",")[1]),
                "city": d.get("city"), "country": d.get("country"), "ip": d.get("ip")}),
]


def _cached() -> dict | None:
    try:
        blob = json.loads(CACHE.read_text())
        if time.time() - blob.get("_ts", 0) < TTL:
            return blob["data"]
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        pass
    return None


def _store(data: dict) -> None:
    CACHE.parent.mkdir(parents=True, exist_ok=True)
    CACHE.write_text(json.dumps({"_ts": time.time(), "data": data}))


def _public_hop_ip() -> str | None:
    """First globally-routable hop on the path to 1.1.1.1 — i.e. the ISP egress
    closest to the user. The private/CGNAT hops before it and the anycast target
    itself (Cloudflare, useless for locating the user) are skipped."""
    try:
        proc = subprocess.run(
            ["traceroute", "-n", "-q", "1", "-w", "2", "-m", "12", TARGET],
            capture_output=True, text=True, timeout=40,
        )
    except (FileNotFoundError, subprocess.SubprocessError) as exc:
        print(f"traceroute: {exc}", file=sys.stderr)
        return None
    for line in proc.stdout.splitlines():
        if line.lower().startswith("traceroute to"):
            continue  # header line names the target; not a hop
        for ip in _IPV4.findall(line):
            if ip == TARGET:
                continue
            try:
                if ipaddress.ip_address(ip).is_global:
                    return ip
            except ValueError:
                continue
    return None


def _fetch(url: str) -> dict:
    req = urllib.request.Request(url, headers={"User-Agent": "astal-menu/1.0"})
    with urllib.request.urlopen(req, timeout=8) as resp:
        return json.loads(resp.read().decode())


def main() -> int:
    if "--no-cache" not in sys.argv:
        hit = _cached()
        if hit:
            print(json.dumps(hit))
            return 0

    hop = _public_hop_ip()
    # Try the traced public hop first, then fall back to our own public IP.
    candidates = ([hop] if hop else []) + [""]
    for ip in candidates:
        for name, url_of, parse in PROVIDERS:
            try:
                data = parse(_fetch(url_of(ip)))
                if data.get("lat") is None or data.get("lon") is None:
                    raise ValueError("no coordinates")
                data["source"] = f"{name} via {ip}" if ip else name
                _store(data)
                print(json.dumps(data))
                return 0
            except Exception as exc:  # noqa: BLE001 — try the next provider/candidate
                print(f"{name}({ip or 'self'}): {exc}", file=sys.stderr)
    print("all geolocation attempts failed", file=sys.stderr)
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
