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