1use crate::db::Database;
2use crate::models::{Sighting, Source};
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::Deserialize;
6
7const GBIF_API_BASE: &str = "https://api.gbif.org/v1/occurrence/search";
8
9#[derive(Debug, Deserialize)]
10struct GBIFResponse {
11 results: Vec<GBIFOccurrence>,
12}
13
14#[derive(Debug, Deserialize)]
15struct GBIFOccurrence {
16 species: Option<String>,
17 scientific_name: Option<String>,
18 decimal_latitude: Option<f64>,
19 decimal_longitude: Option<f64>,
20 event_date: Option<String>,
21 gbif_id: Option<i64>,
22 occurrence_id: Option<String>,
23 #[serde(rename = "verbatimLocality")]
24 verbatim_locality: Option<String>,
25}
26
27pub struct GBIFClient {
28 client: reqwest::Client,
29}
30
31impl GBIFClient {
32 pub fn new() -> Self {
33 Self {
34 client: reqwest::Client::new(),
35 }
36 }
37
38 pub async fn fetch_wolf_sightings(&self, limit: u32) -> Result<Vec<Sighting>> {
39 let url = format!(
40 "{}?speciesKey=5219408&hasCoordinate=true&limit={}",
41 GBIF_API_BASE, limit
42 );
43
44 let response = self
45 .client
46 .get(&url)
47 .header("User-Agent", "Howler/0.1.0 (wolf tracking application)")
48 .send()
49 .await
50 .context("Failed to send GBIF request")?;
51
52 if !response.status().is_success() {
53 anyhow::bail!("GBIF API returned error: {}", response.status());
54 }
55
56 let gbif_response: GBIFResponse = response
57 .json()
58 .await
59 .context("Failed to parse GBIF response")?;
60
61 let sightings = gbif_response
62 .results
63 .into_iter()
64 .filter_map(|occ| self.occurrence_to_sighting(occ))
65 .collect();
66
67 Ok(sightings)
68 }
69
70 fn occurrence_to_sighting(&self, occ: GBIFOccurrence) -> Option<Sighting> {
71 let latitude = occ.decimal_latitude?;
72 let longitude = occ.decimal_longitude?;
73
74 let observed_on = if let Some(date_str) = occ.event_date {
75 DateTime::parse_from_rfc3339(&date_str)
76 .or_else(|_| DateTime::parse_from_rfc2822(&date_str))
77 .map(|dt| dt.with_timezone(&Utc))
78 .ok()
79 } else {
80 None
81 }
82 .unwrap_or_else(Utc::now);
83
84 let source_id = occ
85 .occurrence_id
86 .unwrap_or_else(|| occ.gbif_id.map(|id| id.to_string()).unwrap_or_default());
87
88 Some(Sighting {
89 id: None,
90 species: occ.species.unwrap_or_else(|| "Canis lupus".to_string()),
91 scientific_name: occ.scientific_name,
92 latitude,
93 longitude,
94 observed_on,
95 source: Source::GBIF,
96 source_id,
97 details: occ.verbatim_locality,
98 })
99 }
100}
101
102impl Default for GBIFClient {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108pub async fn fetch_and_cache_gbif(db: &Database, limit: u32) -> Result<usize> {
109 let client = GBIFClient::new();
110 let sightings = client.fetch_wolf_sightings(limit).await?;
111
112 let mut count = 0;
113 for sighting in sightings {
114 db.insert_sighting(&sighting)
115 .context("Failed to insert sighting into database")?;
116 count += 1;
117 }
118
119 Ok(count)
120}