1use crate::error::ThrustError;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use crate::data::faa::nasr::NasrPoint;
6
7#[cfg(feature = "net")]
8const FAA_NAT_URL: &str = "https://notams.aim.faa.gov/nat.html";
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub enum NatDirection {
22 East,
23 West,
24 Both,
25 Unknown,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct NatPoint {
41 pub token: String,
42 pub name: Option<String>,
43 pub latitude: Option<f64>,
44 pub longitude: Option<f64>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct NatTrack {
63 pub track_id: String,
64 pub route_points: Vec<NatPoint>,
65 pub east_levels: Vec<u16>,
66 pub west_levels: Vec<u16>,
67 pub nar_routes: Vec<String>,
68 pub validity: Option<String>,
69 pub source_center: Option<String>,
70}
71
72impl NatTrack {
73 pub fn direction(&self) -> NatDirection {
74 match (self.east_levels.is_empty(), self.west_levels.is_empty()) {
75 (false, true) => NatDirection::East,
76 (true, false) => NatDirection::West,
77 (false, false) => NatDirection::Both,
78 (true, true) => NatDirection::Unknown,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct NatBulletin {
102 pub tracks: Vec<NatTrack>,
103 pub tmi: Option<String>,
104 pub updated_at: Option<String>,
105}
106
107pub fn fetch_nat_bulletin() -> Result<NatBulletin, ThrustError> {
108 #[cfg(not(feature = "net"))]
109 {
110 Err("FAA NAT network fetch is disabled; enable feature 'net'".into())
111 }
112
113 #[cfg(feature = "net")]
114 {
115 let text = reqwest::blocking::Client::new()
116 .get(FAA_NAT_URL)
117 .timeout(std::time::Duration::from_secs(60))
118 .send()?
119 .error_for_status()?
120 .text()?;
121 Ok(parse_nat_bulletin(&text))
122 }
123}
124
125pub fn parse_nat_bulletin(raw: &str) -> NatBulletin {
126 let mut bulletin = NatBulletin::default();
127
128 let normalized = normalize_text(raw);
129 bulletin.updated_at = extract_updated_at(&normalized);
130 bulletin.tmi = extract_tmi(&normalized);
131
132 let mut current_validity: Option<String> = None;
133 let mut current_center: Option<String> = None;
134 let mut current_track: Option<NatTrack> = None;
135
136 for line in normalized.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
137 if line.ends_with("ZOZX") || line.ends_with("ZQZX") {
138 let parts: Vec<&str> = line.split_whitespace().collect();
139 if parts.len() >= 2 {
140 current_center = Some(parts[1].to_string());
141 }
142 continue;
143 }
144
145 if line.contains(" TO ") && line.contains('Z') && line.contains('/') {
146 current_validity = Some(line.to_string());
147 continue;
148 }
149
150 if let Some(track) = parse_track_start_line(line) {
151 if let Some(prev) = current_track.take() {
152 bulletin.tracks.push(prev);
153 }
154 let mut track = track;
155 track.validity = current_validity.clone();
156 track.source_center = current_center.clone();
157 current_track = Some(track);
158 continue;
159 }
160
161 if let Some(track) = current_track.as_mut() {
162 if let Some(levels) = line.strip_prefix("EAST LVLS") {
163 track.east_levels = parse_levels(levels);
164 continue;
165 }
166 if let Some(levels) = line.strip_prefix("WEST LVLS") {
167 track.west_levels = parse_levels(levels);
168 continue;
169 }
170 if let Some(nar) = line.strip_prefix("NAR") {
171 track.nar_routes = parse_nar_routes(nar);
172 continue;
173 }
174 }
175 }
176
177 if let Some(prev) = current_track.take() {
178 bulletin.tracks.push(prev);
179 }
180
181 bulletin
182}
183
184pub fn resolve_named_points_with_nasr(bulletin: &mut NatBulletin, points: &[NasrPoint]) -> usize {
185 let lookup = build_point_lookup(points);
186 let mut resolved = 0usize;
187
188 for track in &mut bulletin.tracks {
189 for point in &mut track.route_points {
190 if point.latitude.is_some() && point.longitude.is_some() {
191 continue;
192 }
193 let key = point.token.to_uppercase();
194 if let Some((lat, lon, name)) = lookup.get(&key) {
195 point.latitude = Some(*lat);
196 point.longitude = Some(*lon);
197 if point.name.is_none() {
198 point.name = Some(name.clone());
199 }
200 resolved += 1;
201 }
202 }
203 }
204
205 resolved
206}
207
208fn build_point_lookup(points: &[NasrPoint]) -> HashMap<String, (f64, f64, String)> {
209 let mut lookup = HashMap::new();
210 for p in points {
211 if p.latitude == 0.0 && p.longitude == 0.0 {
212 continue;
213 }
214
215 let canonical_name = p.name.clone().unwrap_or_else(|| p.identifier.clone());
216 let val = (p.latitude, p.longitude, canonical_name);
217
218 lookup.entry(p.identifier.to_uppercase()).or_insert(val.clone());
219 if let Some(name) = &p.name {
220 lookup.entry(name.to_uppercase()).or_insert(val.clone());
221 }
222
223 let base = p.identifier.split(':').next().unwrap_or(&p.identifier).to_uppercase();
224 lookup.entry(base).or_insert(val);
225 }
226 lookup
227}
228
229fn normalize_text(raw: &str) -> String {
230 raw.replace(['\u{2}', '\u{3}', '\u{b}', '\r'], "\n")
231}
232
233fn extract_updated_at(text: &str) -> Option<String> {
234 text.lines().find_map(|line| {
235 let marker = "Last updated at";
236 if let Some(i) = line.find(marker) {
237 let tail = line[i + marker.len()..].trim();
238 let clean = tail.split('<').next().unwrap_or(tail).trim();
239 if clean.is_empty() {
240 None
241 } else {
242 Some(clean.to_string())
243 }
244 } else {
245 None
246 }
247 })
248}
249
250fn extract_tmi(text: &str) -> Option<String> {
251 text.lines().find_map(|line| {
252 if let Some(idx) = line.find("TMI IS") {
253 let tail = &line[idx + 6..];
254 tail.split(|c: char| !c.is_ascii_alphanumeric())
255 .find(|tok| !tok.is_empty())
256 .map(|s| s.to_string())
257 } else {
258 None
259 }
260 })
261}
262
263fn parse_track_start_line(line: &str) -> Option<NatTrack> {
264 let mut parts = line.split_whitespace();
265 let id = parts.next()?;
266 if id.len() != 1 || !id.chars().all(|c| c.is_ascii_uppercase()) {
267 return None;
268 }
269
270 let route_points = parts
271 .map(|p| p.trim_matches('-'))
272 .filter(|p| !p.is_empty())
273 .map(parse_nat_point)
274 .collect::<Vec<_>>();
275
276 if route_points.len() < 2 {
277 return None;
278 }
279
280 Some(NatTrack {
281 track_id: id.to_string(),
282 route_points,
283 ..Default::default()
284 })
285}
286
287fn parse_nat_point(token: &str) -> NatPoint {
288 let token = token.trim().to_string();
289 if let Some((lat, lon)) = parse_coordinate_token(&token) {
290 NatPoint {
291 token,
292 name: None,
293 latitude: Some(lat),
294 longitude: Some(lon),
295 }
296 } else {
297 NatPoint {
298 token: token.clone(),
299 name: Some(token),
300 latitude: None,
301 longitude: None,
302 }
303 }
304}
305
306fn parse_coordinate_token(token: &str) -> Option<(f64, f64)> {
307 if let Some((lat_s, lon_s)) = token.split_once('/') {
309 let lat = lat_s.parse::<f64>().ok()?;
310 let lon = lon_s.parse::<f64>().ok()?;
311 return Some((lat, -lon));
312 }
313
314 if let Some((lat, lon)) = parse_ddn_dddw(token) {
316 return Some((lat, lon));
317 }
318
319 if let Some((lat, lon)) = parse_ddmmn_dddmmw(token) {
321 return Some((lat, lon));
322 }
323
324 None
325}
326
327fn parse_ddn_dddw(token: &str) -> Option<(f64, f64)> {
328 let b = token.as_bytes();
329 if b.len() < 7 || b.len() > 9 {
330 return None;
331 }
332 let n_pos = token.find('N').or_else(|| token.find('S'))?;
333 let w_pos = token.find('W').or_else(|| token.find('E'))?;
334 if n_pos < 2 || w_pos <= n_pos + 1 {
335 return None;
336 }
337 let lat_deg = token[..n_pos].parse::<f64>().ok()?;
338 let lon_deg = token[n_pos + 1..w_pos].parse::<f64>().ok()?;
339 let lat = if &token[n_pos..=n_pos] == "S" {
340 -lat_deg
341 } else {
342 lat_deg
343 };
344 let lon = if &token[w_pos..=w_pos] == "E" {
345 lon_deg
346 } else {
347 -lon_deg
348 };
349 Some((lat, lon))
350}
351
352fn parse_ddmmn_dddmmw(token: &str) -> Option<(f64, f64)> {
353 let n_pos = token.find('N').or_else(|| token.find('S'))?;
354 let w_pos = token.find('W').or_else(|| token.find('E'))?;
355 if n_pos < 4 || w_pos <= n_pos + 1 {
356 return None;
357 }
358 let lat_raw = &token[..n_pos];
359 let lon_raw = &token[n_pos + 1..w_pos];
360 if lat_raw.len() != 4 || lon_raw.len() != 5 {
361 return None;
362 }
363 let lat_deg = lat_raw[..2].parse::<f64>().ok()?;
364 let lat_min = lat_raw[2..].parse::<f64>().ok()?;
365 let lon_deg = lon_raw[..3].parse::<f64>().ok()?;
366 let lon_min = lon_raw[3..].parse::<f64>().ok()?;
367 let lat = lat_deg + lat_min / 60.0;
368 let lon = lon_deg + lon_min / 60.0;
369 let lat = if &token[n_pos..=n_pos] == "S" { -lat } else { lat };
370 let lon = if &token[w_pos..=w_pos] == "E" { lon } else { -lon };
371 Some((lat, lon))
372}
373
374fn parse_levels(levels_text: &str) -> Vec<u16> {
375 levels_text
376 .split_whitespace()
377 .filter_map(|tok| tok.parse::<u16>().ok())
378 .collect()
379}
380
381fn parse_nar_routes(text: &str) -> Vec<String> {
382 text.split_whitespace()
383 .map(|s| s.trim_matches('-').to_string())
384 .filter(|s| !s.is_empty() && s != "NIL")
385 .collect()
386}