package geo import ( "encoding/json" "encoding/xml" "errors" "fmt" "io" "math" "net/http" "os" "path/filepath" "time" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" ) // Station represents an Environment Canada citypage station. type Station struct { Name string `json:"name"` Code string `json:"code"` Province string `json:"province"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } // stationCacheFile defines where we persist the station index locally. const stationCacheFile = "stations.json" // FindNearestStation locates the closest Environment Canada weather station to given coordinates. func FindNearestStation(lat, lon float64) (Station, error) { if lat == 0 && lon == 0 { return Station{}, errors.New("invalid coordinates: cannot locate nearest station") } stations, err := ensureStationCache() if err != nil { return Station{}, fmt.Errorf("failed to load station list: %w", err) } var nearest Station minDist := math.MaxFloat64 for _, s := range stations { d := Haversine(lat, lon, s.Lat, s.Lon) if d < minDist { minDist = d nearest = s } } if nearest.Code == "" { return Station{}, errors.New("no station found in index") } fmt.Printf("[geo] Nearest station: %s (%s, %.2f km)\n", nearest.Name, nearest.Province, minDist) return nearest, nil } // ensureStationCache loads the cached station list or updates it if missing/outdated. func ensureStationCache() ([]Station, error) { cachePath := filepath.Join(config.DataDir, stationCacheFile) info, err := os.Stat(cachePath) if os.IsNotExist(err) { fmt.Println("[geo] No station cache found, fetching from Environment Canada...") return updateStationCache(cachePath) } if err != nil { return nil, err } // Refresh monthly modTime := info.ModTime().UTC() now := time.Now().UTC() firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) if modTime.Before(firstOfMonth) { fmt.Println("[geo] Station cache is older than this month, refreshing...") return updateStationCache(cachePath) } // Load existing JSON cache f, err := os.Open(cachePath) if err != nil { return nil, err } defer f.Close() var stations []Station if err := json.NewDecoder(f).Decode(&stations); err != nil { return nil, err } return stations, nil } // updateStationCache fetches and parses Environment Canada's current site list (XML). func updateStationCache(dest string) ([]Station, error) { const ecURL = "https://geo.weather.gc.ca/geomet/features/collections/citypage_weather:siteList/items?f=xml" fmt.Println("[geo] Downloading station list from:", ecURL) client := &http.Client{Timeout: 25 * time.Second} resp, err := client.Get(ecURL) if err != nil { return nil, fmt.Errorf("failed to fetch EC station list: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("unexpected HTTP status: %s — %s", resp.Status, string(body)) } type Site struct { Code string `xml:"properties>code"` NameEn string `xml:"properties>nameEn"` NameFr string `xml:"properties>nameFr"` Province string `xml:"properties>provinceCode"` Lat float64 `xml:"geometry>coordinates>1"` // Note: GeoJSON order is [lon, lat] Lon float64 `xml:"geometry>coordinates>0"` } var parsed struct { Sites []Site `xml:"member"` } if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil { return nil, fmt.Errorf("failed to parse site list XML: %w", err) } stations := make([]Station, 0, len(parsed.Sites)) for _, s := range parsed.Sites { if s.Code == "" || s.Lat == 0 || s.Lon == 0 { continue } name := s.NameEn if name == "" { name = s.NameFr } stations = append(stations, Station{ Name: name, Code: s.Code, Province: s.Province, Lat: s.Lat, Lon: s.Lon, }) } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return nil, err } f, err := os.Create(dest) if err != nil { return nil, err } defer f.Close() enc := json.NewEncoder(f) enc.SetIndent("", " ") if err := enc.Encode(stations); err != nil { return nil, err } fmt.Printf("[geo] Saved %d Environment Canada stations → %s\n", len(stations), dest) return stations, nil } // Haversine computes the great-circle distance (in km) between two coordinates. func Haversine(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371 dLat := toRadians(lat2 - lat1) dLon := toRadians(lon2 - lon1) lat1R := toRadians(lat1) lat2R := toRadians(lat2) a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1R)*math.Cos(lat2R)*math.Sin(dLon/2)*math.Sin(dLon/2) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return R * c } func toRadians(deg float64) float64 { return deg * math.Pi / 180 }