// Command geolocate estimates the machine's physical location by scanning
// nearby Wi-Fi access points and submitting their BSSIDs to a public
// Wi-Fi-to-location service, then records the result under ~/.data/location.
//
// This is a macOS port of the original Linux script. Two things changed since
// the original was written:
//
//   - The original scanned access points with nmcli (NetworkManager). macOS
//     has no equivalent, and Apple removed the `airport` CLI in macOS 14.4.
//     On macOS 26, CoreWLAN only returns real BSSIDs to a Developer-ID-signed,
//     notarized binary that the user has granted Location Services access. We
//     get that via the macwifi package, which ships such a signed helper.
//
//   - The original queried Mozilla Location Services, which shut down in 2024.
//     We use BeaconDB instead: a community-run successor that speaks the same
//     Ichnaea API (identical request/response shape).
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"time"

	"github.com/jaisonerick/macwifi"
)

// geolocateURL is BeaconDB's Ichnaea-compatible geolocate endpoint, the
// drop-in replacement for the defunct location.services.mozilla.com.
const geolocateURL = "https://api.beacondb.net/v1/geolocate"

// accessPoint is one observed Wi-Fi access point in the Ichnaea request
// schema. Only macAddress is required; signal strength and channel improve
// the position estimate when available.
type accessPoint struct {
	MacAddress     string `json:"macAddress"`
	SignalStrength int    `json:"signalStrength,omitempty"`
	Channel        int    `json:"channel,omitempty"`
}

type geolocateRequest struct {
	WifiAccessPoints []accessPoint `json:"wifiAccessPoints,omitempty"`
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "geolocate:", err)
		os.Exit(1)
	}
}

func run() error {
	ts := time.Now().Unix()

	// The first scan triggers macOS's Location Services prompt for the
	// bundled helper; allow generous time for a user to approve it.
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	// Wi-Fi scanning requires Location Services. If it is disabled (or the
	// scan otherwise fails), fall back to IP-based geolocation rather than
	// aborting — matching the original, which posted an empty request when
	// it couldn't find enough access points.
	aps, err := listAccessPoints(ctx)
	if err != nil {
		fmt.Fprintf(os.Stderr,
			"geolocate: wifi scan unavailable (%v); falling back to IP geolocation\n", err)
		aps = nil
	}

	loc, err := geolocate(aps)
	if err != nil {
		return err
	}
	loc["timestamp"] = ts

	out, err := json.Marshal(loc)
	if err != nil {
		return err
	}

	home, err := os.UserHomeDir()
	if err != nil {
		return err
	}
	dir := filepath.Join(home, ".data", "location")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}

	if err := writeFile(filepath.Join(dir, "latest.json"), out); err != nil {
		return err
	}
	return writeFile(filepath.Join(dir, fmt.Sprintf("%d.json", ts)), out)
}

func writeFile(path string, content []byte) error {
	return os.WriteFile(path, content, 0o644)
}

// listAccessPoints scans nearby Wi-Fi networks via CoreWLAN (through the
// macwifi helper) and returns their BSSIDs. It returns nil if fewer than two
// usable access points are found, mirroring the original script: the
// geolocation service needs at least two observations to triangulate.
func listAccessPoints(ctx context.Context) ([]accessPoint, error) {
	networks, err := macwifi.Scan(ctx)
	if err != nil {
		return nil, fmt.Errorf("scanning wifi: %w", err)
	}

	seen := make(map[string]bool)
	var aps []accessPoint
	for _, n := range networks {
		// BSSID is empty when Location Services access has not been
		// granted, or for saved-but-not-visible networks.
		if n.BSSID == "" || seen[n.BSSID] {
			continue
		}
		seen[n.BSSID] = true
		aps = append(aps, accessPoint{
			MacAddress:     n.BSSID,
			SignalStrength: n.RSSI,
			Channel:        n.Channel,
		})
	}

	if len(aps) < 2 {
		return nil, nil
	}
	return aps, nil
}

// geolocate posts the observed access points to BeaconDB and returns the
// decoded response (e.g. {"location":{"lat":..,"lng":..},"accuracy":..}).
// With no access points it degrades to a GeoIP estimate, matching the
// original behaviour of posting an empty request.
func geolocate(aps []accessPoint) (map[string]any, error) {
	body, err := json.Marshal(geolocateRequest{WifiAccessPoints: aps})
	if err != nil {
		return nil, err
	}

	resp, err := http.Post(geolocateURL, "application/json", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("geolocation service returned %s: %s",
			resp.Status, bytes.TrimSpace(data))
	}

	var result map[string]any
	if err := json.Unmarshal(data, &result); err != nil {
		return nil, err
	}
	return result, nil
}
