1use crate::config::Config;
2use crate::db::Database;
3use crate::models::{Sighting, Source};
4use anyhow::{Context, Result};
5use chrono::{DateTime, Utc};
6use serde::Deserialize;
7
8const MOVEBANK_API_BASE: &str = "https://www.movebank.org/movebank-service/djson";
9
10#[derive(Debug, Deserialize)]
11#[allow(dead_code)]
12struct MovebankResponse {
13 #[serde(rename = "individuals")]
14 individuals: Option<Vec<MovebankIndividual>>,
15}
16
17#[derive(Debug, Deserialize)]
18#[allow(dead_code)]
19struct MovebankIndividual {
20 #[serde(rename = "individual_id")]
21 individual_id: Option<i64>,
22 #[serde(rename = "individual_local_identifier")]
23 individual_local_identifier: Option<String>,
24 #[serde(rename = "taxon_canonical_name")]
25 taxon_canonical_name: Option<String>,
26}
27
28#[derive(Debug, Deserialize)]
29struct MovebankLocationData {
30 #[serde(rename = "locations")]
31 locations: Option<Vec<MovebankLocation>>,
32}
33
34#[derive(Debug, Deserialize)]
35struct MovebankLocation {
36 #[serde(rename = "location_lat")]
37 location_lat: Option<f64>,
38 #[serde(rename = "location_long")]
39 location_long: Option<f64>,
40 #[serde(rename = "timestamp")]
41 timestamp: Option<String>,
42 #[serde(rename = "individual_id")]
43 individual_id: Option<i64>,
44 #[serde(rename = "individual_local_identifier")]
45 individual_local_identifier: Option<String>,
46}
47
48pub struct MovebankClient {
49 client: reqwest::Client,
50 username: String,
51 password: String,
52}
53
54impl MovebankClient {
55 pub fn new(config: &Config) -> Option<Self> {
56 if !config.has_movebank_credentials() {
57 return None;
58 }
59
60 Some(Self {
61 client: reqwest::Client::new(),
62 username: config.movebank_username.clone().unwrap(),
63 password: config.movebank_password.clone().unwrap(),
64 })
65 }
66
67 pub async fn fetch_wolf_tracks(&self, limit: u32) -> Result<Vec<Sighting>> {
68 let studies_url = format!(
70 "{}?study_id=2928297&individual_local_identifier=*",
71 MOVEBANK_API_BASE
72 );
73
74 let response = self
75 .client
76 .get(&studies_url)
77 .basic_auth(&self.username, Some(&self.password))
78 .header("User-Agent", "Howler/0.1.0")
79 .send()
80 .await
81 .context("Failed to send Movebank request")?;
82
83 if !response.status().is_success() {
84 anyhow::bail!("Movebank API returned error: {}", response.status());
85 }
86
87 let locations_url = format!("{}?study_id=2928297&sensor_type_id=653", MOVEBANK_API_BASE);
90
91 let loc_response = self
92 .client
93 .get(&locations_url)
94 .basic_auth(&self.username, Some(&self.password))
95 .header("User-Agent", "Howler/0.1.0")
96 .send()
97 .await
98 .context("Failed to fetch Movebank locations")?;
99
100 if !loc_response.status().is_success() {
101 return Ok(vec![]);
103 }
104
105 let location_data: MovebankLocationData = loc_response
106 .json()
107 .await
108 .context("Failed to parse Movebank location data")?;
109
110 let sightings = location_data
111 .locations
112 .unwrap_or_default()
113 .into_iter()
114 .filter_map(|loc| self.location_to_sighting(loc))
115 .take(limit as usize)
116 .collect();
117
118 Ok(sightings)
119 }
120
121 fn location_to_sighting(&self, loc: MovebankLocation) -> Option<Sighting> {
122 let latitude = loc.location_lat?;
123 let longitude = loc.location_long?;
124
125 let observed_on = if let Some(ts_str) = loc.timestamp {
126 DateTime::parse_from_rfc3339(&ts_str)
127 .or_else(|_| DateTime::parse_from_rfc2822(&ts_str))
128 .map(|dt| dt.with_timezone(&Utc))
129 .ok()
130 } else {
131 None
132 }
133 .unwrap_or_else(Utc::now);
134
135 let individual_id = loc.individual_local_identifier.unwrap_or_else(|| {
136 loc.individual_id
137 .map(|id| id.to_string())
138 .unwrap_or_default()
139 });
140
141 Some(Sighting {
142 id: None,
143 species: "Canis lupus".to_string(),
144 scientific_name: Some("Canis lupus".to_string()),
145 latitude,
146 longitude,
147 observed_on,
148 source: Source::Movebank,
149 source_id: format!("movebank_{}", individual_id),
150 details: Some(format!("Individual: {}", individual_id)),
151 })
152 }
153}
154
155pub async fn fetch_and_cache_movebank(db: &Database, config: &Config, limit: u32) -> Result<usize> {
156 let client = match MovebankClient::new(config) {
157 Some(c) => c,
158 None => {
159 eprintln!("Movebank credentials not found, skipping Movebank data");
160 return Ok(0);
161 }
162 };
163
164 let sightings = client.fetch_wolf_tracks(limit).await?;
165
166 let mut count = 0;
167 for sighting in sightings {
168 db.insert_sighting(&sighting)
169 .context("Failed to insert sighting into database")?;
170 count += 1;
171 }
172
173 Ok(count)
174}