Skip to main content

unifly_api/
convert.rs

1// ── API-to-domain type conversions ──
2//
3// Bridges raw `unifly_api` response types into canonical `unifly_core::model`
4// domain types. Each `From` impl normalizes field names, parses strings into
5// strong types, and fills sensible defaults for missing optional data.
6
7use std::collections::HashMap;
8use std::net::{IpAddr, Ipv4Addr};
9
10use chrono::{DateTime, Utc};
11use serde_json::Value;
12
13use crate::integration_types;
14use crate::legacy::models::{
15    LegacyAlarm, LegacyClientEntry, LegacyDevice, LegacyEvent, LegacySite,
16};
17use crate::websocket::UnifiEvent;
18
19use crate::model::{
20    client::{Client, ClientType, GuestAuth, WirelessInfo},
21    common::{Bandwidth, DataSource, EntityOrigin},
22    device::{Device, DeviceState, DeviceStats, DeviceType},
23    dns::{DnsPolicy, DnsPolicyType},
24    entity_id::{EntityId, MacAddress},
25    event::{Alarm, Event, EventCategory, EventSeverity},
26    firewall::{
27        AclAction, AclRule, AclRuleType, FirewallAction, FirewallPolicy, FirewallZone, IpSpec,
28        PolicyEndpoint, PortSpec, TrafficFilter,
29    },
30    hotspot::Voucher,
31    network::{DhcpConfig, Ipv6Mode, Network, NetworkManagement},
32    site::Site,
33    supporting::TrafficMatchingList,
34    wifi::{WifiBroadcast, WifiBroadcastType, WifiSecurityMode},
35};
36
37// ── Helpers ────────────────────────────────────────────────────────
38
39/// Parse an optional string to an `IpAddr`, silently dropping unparseable values.
40fn parse_ip(raw: Option<&String>) -> Option<IpAddr> {
41    raw.and_then(|s| s.parse().ok())
42}
43
44/// Convert an optional epoch-seconds timestamp to `DateTime<Utc>`.
45fn epoch_to_datetime(epoch: Option<i64>) -> Option<DateTime<Utc>> {
46    epoch.and_then(|ts| DateTime::from_timestamp(ts, 0))
47}
48
49/// Parse an ISO-8601 datetime string (as returned by the legacy event/alarm endpoints).
50fn parse_datetime(raw: Option<&String>) -> Option<DateTime<Utc>> {
51    raw.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
52        .map(|dt| dt.with_timezone(&Utc))
53}
54
55fn parse_ipv6_text(raw: &str) -> Option<std::net::Ipv6Addr> {
56    let candidate = raw.trim().split('/').next().unwrap_or(raw).trim();
57    candidate.parse::<std::net::Ipv6Addr>().ok()
58}
59
60fn pick_ipv6_from_value(value: &Value) -> Option<String> {
61    let mut first_link_local: Option<String> = None;
62
63    let iter: Box<dyn Iterator<Item = &Value> + '_> = match value {
64        Value::Array(items) => Box::new(items.iter()),
65        _ => Box::new(std::iter::once(value)),
66    };
67
68    for item in iter {
69        if let Some(ipv6) = item.as_str().and_then(parse_ipv6_text) {
70            let ip_text = ipv6.to_string();
71            if !ipv6.is_unicast_link_local() {
72                return Some(ip_text);
73            }
74            if first_link_local.is_none() {
75                first_link_local = Some(ip_text);
76            }
77        }
78    }
79
80    first_link_local
81}
82
83fn parse_legacy_wan_ipv6(extra: &serde_json::Map<String, Value>) -> Option<String> {
84    // Primary source on gateways: wan1.ipv6 = ["global", "link-local"].
85    if let Some(v) = extra
86        .get("wan1")
87        .and_then(|wan| wan.get("ipv6"))
88        .and_then(pick_ipv6_from_value)
89    {
90        return Some(v);
91    }
92
93    // Fallback source on some firmware: top-level ipv6 array.
94    extra.get("ipv6").and_then(pick_ipv6_from_value)
95}
96
97fn extra_bool(extra: &HashMap<String, Value>, key: &str) -> bool {
98    extra.get(key).and_then(Value::as_bool).unwrap_or(false)
99}
100
101fn extra_frequencies(extra: &HashMap<String, Value>, key: &str) -> Vec<f32> {
102    extra
103        .get(key)
104        .and_then(Value::as_array)
105        .map(|values| {
106            #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
107            values
108                .iter()
109                .filter_map(Value::as_f64)
110                .map(|frequency| frequency as f32)
111                .collect()
112        })
113        .unwrap_or_default()
114}
115
116fn dns_value_from_extra(policy_type: DnsPolicyType, extra: &HashMap<String, Value>) -> String {
117    match policy_type {
118        DnsPolicyType::ARecord => extra
119            .get("ipv4Address")
120            .and_then(Value::as_str)
121            .unwrap_or_default()
122            .to_owned(),
123        DnsPolicyType::AaaaRecord => extra
124            .get("ipv6Address")
125            .and_then(Value::as_str)
126            .unwrap_or_default()
127            .to_owned(),
128        DnsPolicyType::CnameRecord => extra
129            .get("targetDomain")
130            .and_then(Value::as_str)
131            .unwrap_or_default()
132            .to_owned(),
133        DnsPolicyType::MxRecord => extra
134            .get("mailServerDomain")
135            .and_then(Value::as_str)
136            .unwrap_or_default()
137            .to_owned(),
138        DnsPolicyType::TxtRecord => extra
139            .get("text")
140            .and_then(Value::as_str)
141            .unwrap_or_default()
142            .to_owned(),
143        DnsPolicyType::SrvRecord => {
144            let server = extra
145                .get("serverDomain")
146                .and_then(Value::as_str)
147                .unwrap_or("");
148            let service = extra.get("service").and_then(Value::as_str).unwrap_or("");
149            let protocol = extra.get("protocol").and_then(Value::as_str).unwrap_or("");
150            let port = extra.get("port").and_then(Value::as_u64);
151            let priority = extra.get("priority").and_then(Value::as_u64);
152            let weight = extra.get("weight").and_then(Value::as_u64);
153
154            let mut parts = Vec::new();
155            if !server.is_empty() {
156                parts.push(server.to_owned());
157            }
158            if !service.is_empty() || !protocol.is_empty() {
159                parts.push(format!("service={service}{protocol}"));
160            }
161            if let Some(port) = port {
162                parts.push(format!("port={port}"));
163            }
164            if let Some(priority) = priority {
165                parts.push(format!("priority={priority}"));
166            }
167            if let Some(weight) = weight {
168                parts.push(format!("weight={weight}"));
169            }
170            parts.join(" ")
171        }
172        DnsPolicyType::ForwardDomain => extra
173            .get("ipAddress")
174            .and_then(Value::as_str)
175            .unwrap_or_default()
176            .to_owned(),
177    }
178}
179
180fn traffic_matching_item_to_string(item: &Value) -> Option<String> {
181    match item {
182        Value::String(value) => Some(value.clone()),
183        Value::Object(map) => {
184            if let Some(value) = map
185                .get("value")
186                .and_then(Value::as_str)
187                .map(str::to_owned)
188                .or_else(|| {
189                    map.get("value")
190                        .and_then(Value::as_i64)
191                        .map(|value| value.to_string())
192                })
193            {
194                return Some(value);
195            }
196
197            let start = map.get("start").or_else(|| map.get("startPort"));
198            let stop = map.get("stop").or_else(|| map.get("endPort"));
199            match (start, stop) {
200                (Some(start), Some(stop)) => {
201                    let start = start
202                        .as_str()
203                        .map(str::to_owned)
204                        .or_else(|| start.as_i64().map(|value| value.to_string()));
205                    let stop = stop
206                        .as_str()
207                        .map(str::to_owned)
208                        .or_else(|| stop.as_i64().map(|value| value.to_string()));
209                    match (start, stop) {
210                        (Some(start), Some(stop)) => Some(format!("{start}-{stop}")),
211                        _ => None,
212                    }
213                }
214                _ => None,
215            }
216        }
217        _ => None,
218    }
219}
220
221// ── Device ─────────────────────────────────────────────────────────
222
223/// Infer `DeviceType` from the legacy `type` field and optional `model` string.
224///
225/// The legacy API `type` field is typically: `"uap"`, `"usw"`, `"ugw"`, `"udm"`.
226/// We also check the `model` prefix for newer hardware that may not match cleanly.
227fn infer_device_type(device_type: &str, model: Option<&String>) -> DeviceType {
228    match device_type {
229        "uap" => DeviceType::AccessPoint,
230        "usw" => DeviceType::Switch,
231        "ugw" | "udm" => DeviceType::Gateway,
232        _ => {
233            // Fallback: check the model string prefix
234            if let Some(m) = model {
235                let upper = m.to_uppercase();
236                if upper.starts_with("UAP") || upper.starts_with("U6") || upper.starts_with("U7") {
237                    DeviceType::AccessPoint
238                } else if upper.starts_with("USW") || upper.starts_with("USL") {
239                    DeviceType::Switch
240                } else if upper.starts_with("UGW")
241                    || upper.starts_with("UDM")
242                    || upper.starts_with("UDR")
243                    || upper.starts_with("UXG")
244                    || upper.starts_with("UCG")
245                    || upper.starts_with("UCK")
246                {
247                    DeviceType::Gateway
248                } else {
249                    DeviceType::Other
250                }
251            } else {
252                DeviceType::Other
253            }
254        }
255    }
256}
257
258/// Map the legacy integer state code to `DeviceState`.
259///
260/// Known codes: 0=offline, 1=online, 2=pending adoption, 4=upgrading, 5=provisioning.
261fn map_device_state(code: i32) -> DeviceState {
262    match code {
263        0 => DeviceState::Offline,
264        1 => DeviceState::Online,
265        2 => DeviceState::PendingAdoption,
266        4 => DeviceState::Updating,
267        5 => DeviceState::GettingReady,
268        _ => DeviceState::Unknown,
269    }
270}
271
272impl From<LegacyDevice> for Device {
273    fn from(d: LegacyDevice) -> Self {
274        let device_type = infer_device_type(&d.device_type, d.model.as_ref());
275        let state = map_device_state(d.state);
276
277        // Build device_stats from sys_stats + uptime
278        let device_stats = {
279            let mut s = DeviceStats {
280                uptime_secs: d.uptime.and_then(|u| u.try_into().ok()),
281                ..Default::default()
282            };
283            if let Some(ref sys) = d.sys_stats {
284                s.load_average_1m = sys.load_1.as_deref().and_then(|v| v.parse().ok());
285                s.load_average_5m = sys.load_5.as_deref().and_then(|v| v.parse().ok());
286                s.load_average_15m = sys.load_15.as_deref().and_then(|v| v.parse().ok());
287                s.cpu_utilization_pct = sys.cpu.as_deref().and_then(|v| v.parse().ok());
288                // Memory utilization as a percentage
289                s.memory_utilization_pct = match (sys.mem_used, sys.mem_total) {
290                    (Some(used), Some(total)) if total > 0 =>
291                    {
292                        #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
293                        Some((used as f64 / total as f64) * 100.0)
294                    }
295                    _ => None,
296                };
297            }
298            s
299        };
300
301        Device {
302            id: EntityId::from(d.id),
303            mac: MacAddress::new(&d.mac),
304            ip: parse_ip(d.ip.as_ref()),
305            wan_ipv6: parse_legacy_wan_ipv6(&d.extra),
306            name: d.name,
307            model: d.model,
308            device_type,
309            state,
310            firmware_version: d.version,
311            firmware_updatable: d.upgradable.unwrap_or(false),
312            adopted_at: None, // Legacy API doesn't provide adoption timestamp
313            provisioned_at: None,
314            last_seen: epoch_to_datetime(d.last_seen),
315            serial: d.serial,
316            supported: true, // Legacy API only returns adopted/supported devices
317            ports: Vec::new(),
318            radios: Vec::new(),
319            uplink_device_id: None,
320            uplink_device_mac: None,
321            has_switching: device_type == DeviceType::Switch || device_type == DeviceType::Gateway,
322            has_access_point: device_type == DeviceType::AccessPoint,
323            stats: device_stats,
324            client_count: d.num_sta.and_then(|n| n.try_into().ok()),
325            origin: None,
326            source: DataSource::LegacyApi,
327            updated_at: Utc::now(),
328        }
329    }
330}
331
332// ── Client ─────────────────────────────────────────────────────────
333
334impl From<LegacyClientEntry> for Client {
335    fn from(c: LegacyClientEntry) -> Self {
336        let is_wired = c.is_wired.unwrap_or(false);
337        let client_type = if is_wired {
338            ClientType::Wired
339        } else {
340            ClientType::Wireless
341        };
342
343        // Build wireless info for non-wired clients
344        let wireless = if is_wired {
345            None
346        } else {
347            Some(WirelessInfo {
348                ssid: c.essid.clone(),
349                bssid: c.bssid.as_deref().map(MacAddress::new),
350                channel: c.channel.and_then(|ch| ch.try_into().ok()),
351                frequency_ghz: channel_to_frequency(c.channel),
352                signal_dbm: c.signal.or(c.rssi),
353                noise_dbm: c.noise,
354                satisfaction: c.satisfaction.and_then(|s| s.try_into().ok()),
355                tx_rate_kbps: c.tx_rate.and_then(|r| r.try_into().ok()),
356                rx_rate_kbps: c.rx_rate.and_then(|r| r.try_into().ok()),
357            })
358        };
359
360        // Build guest auth if the client is a guest
361        let is_guest = c.is_guest.unwrap_or(false);
362        let guest_auth = if is_guest {
363            Some(GuestAuth {
364                authorized: c.authorized.unwrap_or(false),
365                method: None,
366                expires_at: None,
367                tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
368                rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
369                elapsed_minutes: None,
370            })
371        } else {
372            None
373        };
374
375        // Determine uplink device MAC based on connection type
376        let uplink_device_mac = if is_wired {
377            c.sw_mac.as_deref().map(MacAddress::new)
378        } else {
379            c.ap_mac.as_deref().map(MacAddress::new)
380        };
381
382        // Estimate connected_at from uptime
383        let connected_at = c.uptime.and_then(|secs| {
384            let duration = chrono::Duration::seconds(secs);
385            Utc::now().checked_sub_signed(duration)
386        });
387
388        Client {
389            id: EntityId::from(c.id),
390            mac: MacAddress::new(&c.mac),
391            ip: parse_ip(c.ip.as_ref()),
392            name: c.name,
393            hostname: c.hostname,
394            client_type,
395            connected_at,
396            uplink_device_id: None,
397            uplink_device_mac,
398            network_id: c.network_id.map(EntityId::from),
399            vlan: None,
400            wireless,
401            guest_auth,
402            is_guest,
403            tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
404            rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
405            bandwidth: None,
406            os_name: None,
407            device_class: None,
408            use_fixedip: false,
409            fixed_ip: None,
410            blocked: c.blocked.unwrap_or(false),
411            source: DataSource::LegacyApi,
412            updated_at: Utc::now(),
413        }
414    }
415}
416
417/// Rough channel-to-frequency mapping for common Wi-Fi channels.
418fn channel_to_frequency(channel: Option<i32>) -> Option<f32> {
419    channel.map(|ch| match ch {
420        1..=14 => 2.4,
421        32..=68 | 96..=177 => 5.0,
422        _ => 6.0, // Wi-Fi 6E / 7
423    })
424}
425
426// ── Site ───────────────────────────────────────────────────────────
427
428impl From<LegacySite> for Site {
429    fn from(s: LegacySite) -> Self {
430        // `desc` is the human-friendly label; `name` is the internal slug (e.g. "default").
431        let display_name = s
432            .desc
433            .filter(|d| !d.is_empty())
434            .unwrap_or_else(|| s.name.clone());
435
436        Site {
437            id: EntityId::from(s.id),
438            internal_name: s.name,
439            name: display_name,
440            device_count: None,
441            client_count: None,
442            source: DataSource::LegacyApi,
443        }
444    }
445}
446
447// ── Event ──────────────────────────────────────────────────────────
448
449/// Map legacy subsystem string to `EventCategory`.
450fn map_event_category(subsystem: Option<&String>) -> EventCategory {
451    match subsystem.map(String::as_str) {
452        Some("wlan" | "lan" | "wan") => EventCategory::Network,
453        Some("device") => EventCategory::Device,
454        Some("client") => EventCategory::Client,
455        Some("system") => EventCategory::System,
456        Some("admin") => EventCategory::Admin,
457        Some("firewall") => EventCategory::Firewall,
458        Some("vpn") => EventCategory::Vpn,
459        _ => EventCategory::Unknown,
460    }
461}
462
463impl From<LegacyEvent> for Event {
464    fn from(e: LegacyEvent) -> Self {
465        Event {
466            id: Some(EntityId::from(e.id)),
467            timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
468            category: map_event_category(e.subsystem.as_ref()),
469            severity: EventSeverity::Info,
470            event_type: e.key.clone().unwrap_or_default(),
471            message: e.msg.unwrap_or_default(),
472            device_mac: None,
473            client_mac: None,
474            site_id: e.site_id.map(EntityId::from),
475            raw_key: e.key,
476            source: DataSource::LegacyApi,
477        }
478    }
479}
480
481// ── Alarm → Event ──────────────────────────────────────────────────
482
483impl From<LegacyAlarm> for Event {
484    fn from(a: LegacyAlarm) -> Self {
485        Event {
486            id: Some(EntityId::from(a.id)),
487            timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
488            category: EventCategory::System,
489            severity: EventSeverity::Warning,
490            event_type: a.key.clone().unwrap_or_default(),
491            message: a.msg.unwrap_or_default(),
492            device_mac: None,
493            client_mac: None,
494            site_id: None,
495            raw_key: a.key,
496            source: DataSource::LegacyApi,
497        }
498    }
499}
500
501impl From<LegacyAlarm> for Alarm {
502    fn from(a: LegacyAlarm) -> Self {
503        Alarm {
504            id: EntityId::from(a.id),
505            timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
506            category: EventCategory::System,
507            severity: EventSeverity::Warning,
508            message: a.msg.unwrap_or_default(),
509            archived: a.archived.unwrap_or(false),
510            device_mac: None,
511            site_id: None,
512        }
513    }
514}
515
516// ── WebSocket Event ──────────────────────────────────────────────
517
518/// Infer severity from a WebSocket event key.
519///
520/// Disconnect/Lost/Down keywords → Warning, Error/Fail → Error, else Info.
521fn infer_ws_severity(key: &str) -> EventSeverity {
522    let upper = key.to_uppercase();
523    if upper.contains("ERROR") || upper.contains("FAIL") {
524        EventSeverity::Error
525    } else if upper.contains("DISCONNECT") || upper.contains("LOST") || upper.contains("DOWN") {
526        EventSeverity::Warning
527    } else {
528        EventSeverity::Info
529    }
530}
531
532impl From<UnifiEvent> for Event {
533    fn from(e: UnifiEvent) -> Self {
534        let category = map_event_category(Some(&e.subsystem));
535        let severity = infer_ws_severity(&e.key);
536
537        // Extract device MAC from common extra fields
538        let device_mac = e
539            .extra
540            .get("mac")
541            .or_else(|| e.extra.get("sw"))
542            .or_else(|| e.extra.get("ap"))
543            .and_then(|v| v.as_str())
544            .map(MacAddress::new);
545
546        // Extract client MAC from common extra fields
547        let client_mac = e
548            .extra
549            .get("user")
550            .or_else(|| e.extra.get("sta"))
551            .and_then(|v| v.as_str())
552            .map(MacAddress::new);
553
554        let site_id = if e.site_id.is_empty() {
555            None
556        } else {
557            Some(EntityId::Legacy(e.site_id))
558        };
559
560        Event {
561            id: None,
562            timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
563            category,
564            severity,
565            event_type: e.key.clone(),
566            message: e.message.unwrap_or_default(),
567            device_mac,
568            client_mac,
569            site_id,
570            raw_key: Some(e.key),
571            source: DataSource::LegacyApi,
572        }
573    }
574}
575
576// ━━ Integration API conversions ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
577
578// ── Helpers ────────────────────────────────────────────────────────
579
580/// Parse an ISO-8601 string (Integration API format) to `DateTime<Utc>`.
581fn parse_iso(raw: &str) -> Option<DateTime<Utc>> {
582    DateTime::parse_from_rfc3339(raw)
583        .ok()
584        .map(|dt| dt.with_timezone(&Utc))
585}
586
587/// Map Integration API management string to `EntityOrigin`.
588fn map_origin(management: &str) -> Option<EntityOrigin> {
589    match management {
590        "USER_DEFINED" => Some(EntityOrigin::UserDefined),
591        "SYSTEM_DEFINED" => Some(EntityOrigin::SystemDefined),
592        "ORCHESTRATED" => Some(EntityOrigin::Orchestrated),
593        _ => None,
594    }
595}
596
597/// Extract origin from a `metadata` JSON object.
598///
599/// Checks `metadata.origin` (real API) and `metadata.management` (spec)
600/// since the field name varies by firmware version.
601fn origin_from_metadata(metadata: &serde_json::Value) -> Option<EntityOrigin> {
602    metadata
603        .get("origin")
604        .or_else(|| metadata.get("management"))
605        .and_then(|v| v.as_str())
606        .and_then(map_origin)
607}
608
609/// Map Integration API device state string to `DeviceState`.
610fn map_integration_device_state(state: &str) -> DeviceState {
611    match state {
612        "ONLINE" => DeviceState::Online,
613        "OFFLINE" => DeviceState::Offline,
614        "PENDING_ADOPTION" => DeviceState::PendingAdoption,
615        "UPDATING" => DeviceState::Updating,
616        "GETTING_READY" => DeviceState::GettingReady,
617        "ADOPTING" => DeviceState::Adopting,
618        "DELETING" => DeviceState::Deleting,
619        "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
620        "ISOLATED" => DeviceState::Isolated,
621        _ => DeviceState::Unknown,
622    }
623}
624
625/// Infer `DeviceType` from Integration API `features` list and `model` string.
626fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
627    let has = |f: &str| features.iter().any(|s| s == f);
628
629    // Check model prefix first — some gateways (UCG Max) report "switching"
630    // without "routing", which would misclassify them as switches.
631    let upper = model.to_uppercase();
632    let is_gateway_model = upper.starts_with("UGW")
633        || upper.starts_with("UDM")
634        || upper.starts_with("UDR")
635        || upper.starts_with("UXG")
636        || upper.starts_with("UCG")
637        || upper.starts_with("UCK");
638
639    if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
640        DeviceType::Gateway
641    } else if has("accessPoint") {
642        DeviceType::AccessPoint
643    } else if has("switching") {
644        DeviceType::Switch
645    } else {
646        // Fallback to model prefix
647        let model_owned = model.to_owned();
648        infer_device_type("", Some(&model_owned))
649    }
650}
651
652// ── Device ────────────────────────────────────────────────────────
653
654impl From<integration_types::DeviceResponse> for Device {
655    fn from(d: integration_types::DeviceResponse) -> Self {
656        let device_type = infer_device_type_integration(&d.features, &d.model);
657        let state = map_integration_device_state(&d.state);
658
659        Device {
660            id: EntityId::Uuid(d.id),
661            mac: MacAddress::new(&d.mac_address),
662            ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
663            wan_ipv6: None,
664            name: Some(d.name),
665            model: Some(d.model),
666            device_type,
667            state,
668            firmware_version: d.firmware_version,
669            firmware_updatable: d.firmware_updatable,
670            adopted_at: None,
671            provisioned_at: None,
672            last_seen: None,
673            serial: None,
674            supported: d.supported,
675            ports: Vec::new(),
676            radios: Vec::new(),
677            uplink_device_id: None,
678            uplink_device_mac: None,
679            has_switching: d.features.iter().any(|f| f == "switching"),
680            has_access_point: d.features.iter().any(|f| f == "accessPoint"),
681            stats: DeviceStats::default(),
682            client_count: None,
683            origin: None,
684            source: DataSource::IntegrationApi,
685            updated_at: Utc::now(),
686        }
687    }
688}
689
690/// Convert Integration API device statistics into domain `DeviceStats`.
691pub(crate) fn device_stats_from_integration(
692    resp: &integration_types::DeviceStatisticsResponse,
693) -> DeviceStats {
694    DeviceStats {
695        uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
696        cpu_utilization_pct: resp.cpu_utilization_pct,
697        memory_utilization_pct: resp.memory_utilization_pct,
698        load_average_1m: resp.load_average_1_min,
699        load_average_5m: resp.load_average_5_min,
700        load_average_15m: resp.load_average_15_min,
701        last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
702        next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
703        uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
704            let tx = u
705                .get("txRateBps")
706                .or_else(|| u.get("txBytesPerSecond"))
707                .or_else(|| u.get("tx_bytes-r"))
708                .and_then(serde_json::Value::as_u64)
709                .unwrap_or(0);
710            let rx = u
711                .get("rxRateBps")
712                .or_else(|| u.get("rxBytesPerSecond"))
713                .or_else(|| u.get("rx_bytes-r"))
714                .and_then(serde_json::Value::as_u64)
715                .unwrap_or(0);
716            if tx == 0 && rx == 0 {
717                None
718            } else {
719                Some(Bandwidth {
720                    tx_bytes_per_sec: tx,
721                    rx_bytes_per_sec: rx,
722                })
723            }
724        }),
725    }
726}
727
728// ── Client ────────────────────────────────────────────────────────
729
730impl From<integration_types::ClientResponse> for Client {
731    fn from(c: integration_types::ClientResponse) -> Self {
732        let client_type = match c.client_type.as_str() {
733            "WIRED" => ClientType::Wired,
734            "WIRELESS" => ClientType::Wireless,
735            "VPN" => ClientType::Vpn,
736            "TELEPORT" => ClientType::Teleport,
737            _ => ClientType::Unknown,
738        };
739
740        // Extract MAC from access object; fall back to UUID so clients
741        // without a macAddress still get unique store keys.
742        let mac_from_access = c
743            .access
744            .get("macAddress")
745            .and_then(|v| v.as_str())
746            .unwrap_or("")
747            .to_string();
748        let uuid_fallback = c.id.to_string();
749        let mac_str = if mac_from_access.is_empty() {
750            uuid_fallback.as_str()
751        } else {
752            mac_from_access.as_str()
753        };
754
755        Client {
756            id: EntityId::Uuid(c.id),
757            mac: MacAddress::new(mac_str),
758            ip: c.ip_address.as_deref().and_then(|s| s.parse().ok()),
759            name: Some(c.name),
760            hostname: None,
761            client_type,
762            connected_at: c.connected_at.as_deref().and_then(parse_iso),
763            uplink_device_id: None,
764            uplink_device_mac: None,
765            network_id: None,
766            vlan: None,
767            wireless: None,
768            guest_auth: None,
769            is_guest: false,
770            tx_bytes: None,
771            rx_bytes: None,
772            bandwidth: None,
773            os_name: None,
774            device_class: None,
775            use_fixedip: false,
776            fixed_ip: None,
777            blocked: false,
778            source: DataSource::IntegrationApi,
779            updated_at: Utc::now(),
780        }
781    }
782}
783
784// ── Site ──────────────────────────────────────────────────────────
785
786impl From<integration_types::SiteResponse> for Site {
787    fn from(s: integration_types::SiteResponse) -> Self {
788        Site {
789            id: EntityId::Uuid(s.id),
790            internal_name: s.internal_reference,
791            name: s.name,
792            device_count: None,
793            client_count: None,
794            source: DataSource::IntegrationApi,
795        }
796    }
797}
798
799// ── Network ──────────────────────────────────────────────────────
800
801/// Look up a field in `extra` first, then fall back to `metadata`.
802fn net_field<'a>(
803    extra: &'a HashMap<String, Value>,
804    metadata: &'a Value,
805    key: &str,
806) -> Option<&'a Value> {
807    extra.get(key).or_else(|| metadata.get(key))
808}
809
810/// Parse network configuration from API extra/metadata fields into a `Network`.
811#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
812fn parse_network_fields(
813    id: uuid::Uuid,
814    name: String,
815    enabled: bool,
816    management_str: &str,
817    vlan_id: i32,
818    is_default: bool,
819    metadata: &Value,
820    extra: &HashMap<String, Value>,
821) -> Network {
822    // ── Feature flags ───────────────────────────────────────────
823    let isolation_enabled = net_field(extra, metadata, "isolationEnabled")
824        .and_then(Value::as_bool)
825        .unwrap_or(false);
826    let internet_access_enabled = net_field(extra, metadata, "internetAccessEnabled")
827        .and_then(Value::as_bool)
828        .unwrap_or(true);
829    let mdns_forwarding_enabled = net_field(extra, metadata, "mdnsForwardingEnabled")
830        .and_then(Value::as_bool)
831        .unwrap_or(false);
832    let cellular_backup_enabled = net_field(extra, metadata, "cellularBackupEnabled")
833        .and_then(Value::as_bool)
834        .unwrap_or(false);
835
836    // ── Firewall zone ───────────────────────────────────────────
837    let firewall_zone_id = net_field(extra, metadata, "zoneId")
838        .and_then(Value::as_str)
839        .and_then(|s| uuid::Uuid::parse_str(s).ok())
840        .map(EntityId::Uuid);
841
842    // ── IPv4 configuration ──────────────────────────────────────
843    // Detail API uses: hostIpAddress, prefixLength, dhcpConfiguration
844    // Some firmware uses: host, prefix, dhcp.server
845    let ipv4 = net_field(extra, metadata, "ipv4Configuration");
846
847    let gateway_ip: Option<Ipv4Addr> = ipv4
848        .and_then(|v| v.get("hostIpAddress").or_else(|| v.get("host")))
849        .and_then(Value::as_str)
850        .and_then(|s| s.parse().ok());
851
852    let subnet = ipv4.and_then(|v| {
853        let host = v.get("hostIpAddress").or_else(|| v.get("host"))?.as_str()?;
854        let prefix = v
855            .get("prefixLength")
856            .or_else(|| v.get("prefix"))?
857            .as_u64()?;
858        Some(format!("{host}/{prefix}"))
859    });
860
861    // ── DHCP ────────────────────────────────────────────────────
862    // Detail API: dhcpConfiguration.mode/leaseTimeSeconds/ipAddressRange/dnsServerIpAddressesOverride
863    // Fallback:   dhcp.server.enabled/rangeStart/rangeStop/leaseTimeSec/dnsOverride.servers
864    let dhcp = ipv4.and_then(|v| {
865        // Try new-style dhcpConfiguration first
866        if let Some(dhcp_cfg) = v.get("dhcpConfiguration") {
867            let mode = dhcp_cfg.get("mode").and_then(Value::as_str).unwrap_or("");
868            let dhcp_enabled = mode == "SERVER";
869            let range = dhcp_cfg.get("ipAddressRange");
870            let range_start = range
871                .and_then(|r| r.get("start").or_else(|| r.get("rangeStart")))
872                .and_then(Value::as_str)
873                .and_then(|s| s.parse().ok());
874            let range_stop = range
875                .and_then(|r| r.get("end").or_else(|| r.get("rangeStop")))
876                .and_then(Value::as_str)
877                .and_then(|s| s.parse().ok());
878            let lease_time_secs = dhcp_cfg.get("leaseTimeSeconds").and_then(Value::as_u64);
879            let dns_servers = dhcp_cfg
880                .get("dnsServerIpAddressesOverride")
881                .and_then(Value::as_array)
882                .map(|arr| {
883                    arr.iter()
884                        .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
885                        .collect()
886                })
887                .unwrap_or_default();
888            return Some(DhcpConfig {
889                enabled: dhcp_enabled,
890                range_start,
891                range_stop,
892                lease_time_secs,
893                dns_servers,
894                gateway: gateway_ip,
895            });
896        }
897
898        // Fallback: old-style dhcp.server
899        let server = v.get("dhcp")?.get("server")?;
900        let dhcp_enabled = server
901            .get("enabled")
902            .and_then(Value::as_bool)
903            .unwrap_or(false);
904        let range_start = server
905            .get("rangeStart")
906            .and_then(Value::as_str)
907            .and_then(|s| s.parse().ok());
908        let range_stop = server
909            .get("rangeStop")
910            .and_then(Value::as_str)
911            .and_then(|s| s.parse().ok());
912        let lease_time_secs = server.get("leaseTimeSec").and_then(Value::as_u64);
913        let dns_servers = server
914            .get("dnsOverride")
915            .and_then(|d| d.get("servers"))
916            .and_then(Value::as_array)
917            .map(|arr| {
918                arr.iter()
919                    .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
920                    .collect()
921            })
922            .unwrap_or_default();
923        let gateway = server
924            .get("gateway")
925            .and_then(Value::as_str)
926            .and_then(|s| s.parse().ok())
927            .or(gateway_ip);
928        Some(DhcpConfig {
929            enabled: dhcp_enabled,
930            range_start,
931            range_stop,
932            lease_time_secs,
933            dns_servers,
934            gateway,
935        })
936    });
937
938    // ── PXE / NTP / TFTP ────────────────────────────────────────
939    let pxe_enabled = ipv4
940        .and_then(|v| v.get("pxe"))
941        .and_then(|v| v.get("enabled"))
942        .and_then(Value::as_bool)
943        .unwrap_or(false);
944    let ntp_server = ipv4
945        .and_then(|v| v.get("ntp"))
946        .and_then(|v| v.get("server"))
947        .and_then(Value::as_str)
948        .and_then(|s| s.parse::<IpAddr>().ok());
949    let tftp_server = ipv4
950        .and_then(|v| v.get("tftp"))
951        .and_then(|v| v.get("server"))
952        .and_then(Value::as_str)
953        .map(String::from);
954
955    // ── IPv6 ────────────────────────────────────────────────────
956    // Detail API: interfaceType, clientAddressAssignment.slaacEnabled, additionalHostIpSubnets
957    // Fallback:   type, slaac.enabled, dhcpv6.enabled, prefix
958    let ipv6 = net_field(extra, metadata, "ipv6Configuration");
959    let ipv6_enabled = ipv6.is_some();
960    let ipv6_mode = ipv6
961        .and_then(|v| v.get("interfaceType").or_else(|| v.get("type")))
962        .and_then(Value::as_str)
963        .and_then(|s| match s {
964            "PREFIX_DELEGATION" => Some(Ipv6Mode::PrefixDelegation),
965            "STATIC" => Some(Ipv6Mode::Static),
966            _ => None,
967        });
968    let slaac_enabled = ipv6
969        .and_then(|v| {
970            // New: clientAddressAssignment.slaacEnabled
971            v.get("clientAddressAssignment")
972                .and_then(|ca| ca.get("slaacEnabled"))
973                .and_then(Value::as_bool)
974                // Fallback: slaac.enabled
975                .or_else(|| v.get("slaac").and_then(|s| s.get("enabled")).and_then(Value::as_bool))
976        })
977        .unwrap_or(false);
978    let dhcpv6_enabled = ipv6
979        .and_then(|v| {
980            v.get("clientAddressAssignment")
981                .and_then(|ca| ca.get("dhcpv6Enabled"))
982                .and_then(Value::as_bool)
983                .or_else(|| {
984                    v.get("dhcpv6")
985                        .and_then(|d| d.get("enabled"))
986                        .and_then(Value::as_bool)
987                })
988        })
989        .unwrap_or(false);
990    let ipv6_prefix = ipv6.and_then(|v| {
991        // New: additionalHostIpSubnets[0]
992        v.get("additionalHostIpSubnets")
993                .and_then(Value::as_array)
994                .and_then(|a| a.first())
995                .and_then(Value::as_str)
996                .map(String::from)
997                // Fallback: prefix
998                .or_else(|| v.get("prefix").and_then(Value::as_str).map(String::from))
999    });
1000
1001    // ── Management type inference ───────────────────────────────
1002    let has_ipv4_config = ipv4.is_some();
1003    let has_device_id = extra.contains_key("deviceId");
1004    let management = if has_ipv4_config && !has_device_id {
1005        Some(NetworkManagement::Gateway)
1006    } else if has_device_id {
1007        Some(NetworkManagement::Switch)
1008    } else if has_ipv4_config {
1009        Some(NetworkManagement::Gateway)
1010    } else {
1011        None
1012    };
1013
1014    Network {
1015        id: EntityId::Uuid(id),
1016        name,
1017        enabled,
1018        management,
1019        purpose: None,
1020        is_default,
1021        #[allow(
1022            clippy::as_conversions,
1023            clippy::cast_possible_truncation,
1024            clippy::cast_sign_loss
1025        )]
1026        vlan_id: Some(vlan_id as u16),
1027        subnet,
1028        gateway_ip,
1029        dhcp,
1030        ipv6_enabled,
1031        ipv6_mode,
1032        ipv6_prefix,
1033        dhcpv6_enabled,
1034        slaac_enabled,
1035        ntp_server,
1036        pxe_enabled,
1037        tftp_server,
1038        firewall_zone_id,
1039        isolation_enabled,
1040        internet_access_enabled,
1041        mdns_forwarding_enabled,
1042        cellular_backup_enabled,
1043        origin: map_origin(management_str),
1044        source: DataSource::IntegrationApi,
1045    }
1046}
1047
1048impl From<integration_types::NetworkResponse> for Network {
1049    fn from(n: integration_types::NetworkResponse) -> Self {
1050        parse_network_fields(
1051            n.id,
1052            n.name,
1053            n.enabled,
1054            &n.management,
1055            n.vlan_id,
1056            n.default,
1057            &n.metadata,
1058            &n.extra,
1059        )
1060    }
1061}
1062
1063impl From<integration_types::NetworkDetailsResponse> for Network {
1064    fn from(n: integration_types::NetworkDetailsResponse) -> Self {
1065        parse_network_fields(
1066            n.id,
1067            n.name,
1068            n.enabled,
1069            &n.management,
1070            n.vlan_id,
1071            n.default,
1072            &n.metadata,
1073            &n.extra,
1074        )
1075    }
1076}
1077
1078// ── WiFi Broadcast ───────────────────────────────────────────────
1079
1080impl From<integration_types::WifiBroadcastResponse> for WifiBroadcast {
1081    fn from(w: integration_types::WifiBroadcastResponse) -> Self {
1082        let broadcast_type = match w.broadcast_type.as_str() {
1083            "IOT_OPTIMIZED" => WifiBroadcastType::IotOptimized,
1084            _ => WifiBroadcastType::Standard,
1085        };
1086
1087        let security = w
1088            .security_configuration
1089            .get("mode")
1090            .and_then(|v| v.as_str())
1091            .map_or(WifiSecurityMode::Open, |mode| match mode {
1092                "WPA2_PERSONAL" => WifiSecurityMode::Wpa2Personal,
1093                "WPA3_PERSONAL" => WifiSecurityMode::Wpa3Personal,
1094                "WPA2_WPA3_PERSONAL" => WifiSecurityMode::Wpa2Wpa3Personal,
1095                "WPA2_ENTERPRISE" => WifiSecurityMode::Wpa2Enterprise,
1096                "WPA3_ENTERPRISE" => WifiSecurityMode::Wpa3Enterprise,
1097                "WPA2_WPA3_ENTERPRISE" => WifiSecurityMode::Wpa2Wpa3Enterprise,
1098                _ => WifiSecurityMode::Open,
1099            });
1100
1101        WifiBroadcast {
1102            id: EntityId::Uuid(w.id),
1103            name: w.name,
1104            enabled: w.enabled,
1105            broadcast_type,
1106            security,
1107            network_id: w
1108                .network
1109                .as_ref()
1110                .and_then(|v| v.get("id"))
1111                .and_then(|v| v.as_str())
1112                .and_then(|s| uuid::Uuid::parse_str(s).ok())
1113                .map(EntityId::Uuid),
1114            frequencies_ghz: extra_frequencies(&w.extra, "broadcastingFrequenciesGHz"),
1115            hidden: extra_bool(&w.extra, "hideName"),
1116            client_isolation: extra_bool(&w.extra, "clientIsolationEnabled"),
1117            band_steering: extra_bool(&w.extra, "bandSteeringEnabled"),
1118            mlo_enabled: extra_bool(&w.extra, "mloEnabled"),
1119            fast_roaming: extra_bool(&w.extra, "bssTransitionEnabled"),
1120            hotspot_enabled: w.extra.contains_key("hotspotConfiguration"),
1121            origin: origin_from_metadata(&w.metadata),
1122            source: DataSource::IntegrationApi,
1123        }
1124    }
1125}
1126
1127// ── Firewall Policy ──────────────────────────────────────────────
1128
1129impl From<integration_types::FirewallPolicyResponse> for FirewallPolicy {
1130    fn from(p: integration_types::FirewallPolicyResponse) -> Self {
1131        let action = p.action.get("type").and_then(|v| v.as_str()).map_or(
1132            FirewallAction::Block,
1133            |a| match a {
1134                "ALLOW" => FirewallAction::Allow,
1135                "REJECT" => FirewallAction::Reject,
1136                _ => FirewallAction::Block,
1137            },
1138        );
1139
1140        #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1141        let index = p
1142            .extra
1143            .get("index")
1144            .and_then(serde_json::Value::as_i64)
1145            .map(|i| i as i32);
1146
1147        // Convert structured source/destination, falling back to flat zone IDs
1148        let source_endpoint =
1149            convert_policy_endpoint(p.source.as_ref(), p.extra.get("sourceFirewallZoneId"));
1150        let destination_endpoint = convert_dest_policy_endpoint(
1151            p.destination.as_ref(),
1152            p.extra.get("destinationFirewallZoneId"),
1153        );
1154
1155        let source_summary = source_endpoint.filter.as_ref().map(TrafficFilter::summary);
1156        let destination_summary = destination_endpoint
1157            .filter
1158            .as_ref()
1159            .map(TrafficFilter::summary);
1160
1161        // Extract IP version from ipProtocolScope
1162        let ip_version = p
1163            .ip_protocol_scope
1164            .as_ref()
1165            .and_then(|v| v.get("ipVersion"))
1166            .and_then(|v| v.as_str())
1167            .map_or(crate::model::firewall::IpVersion::Both, |s| match s {
1168                "IPV4_ONLY" | "IPV4" => crate::model::firewall::IpVersion::Ipv4,
1169                "IPV6_ONLY" | "IPV6" => crate::model::firewall::IpVersion::Ipv6,
1170                _ => crate::model::firewall::IpVersion::Both,
1171            });
1172
1173        let ipsec_mode = p
1174            .extra
1175            .get("ipsecFilter")
1176            .and_then(|v| v.as_str())
1177            .map(String::from);
1178
1179        let connection_states = p
1180            .extra
1181            .get("connectionStateFilter")
1182            .and_then(|v| v.as_array())
1183            .map(|arr| {
1184                arr.iter()
1185                    .filter_map(|v| v.as_str().map(String::from))
1186                    .collect()
1187            })
1188            .unwrap_or_default();
1189
1190        FirewallPolicy {
1191            id: EntityId::Uuid(p.id),
1192            name: p.name,
1193            description: p.description,
1194            enabled: p.enabled,
1195            index,
1196            action,
1197            ip_version,
1198            source: source_endpoint,
1199            destination: destination_endpoint,
1200            source_summary,
1201            destination_summary,
1202            protocol_summary: None,
1203            schedule: None,
1204            ipsec_mode,
1205            connection_states,
1206            logging_enabled: p.logging_enabled,
1207            origin: p.metadata.as_ref().and_then(origin_from_metadata),
1208            data_source: DataSource::IntegrationApi,
1209        }
1210    }
1211}
1212
1213/// Convert API source/destination to a domain `PolicyEndpoint`.
1214/// Falls back to flat zone ID fields from the `extra` map.
1215fn convert_policy_endpoint(
1216    endpoint: Option<&integration_types::FirewallPolicySource>,
1217    flat_zone_id: Option<&serde_json::Value>,
1218) -> PolicyEndpoint {
1219    if let Some(ep) = endpoint {
1220        PolicyEndpoint {
1221            zone_id: ep.zone_id.map(EntityId::Uuid),
1222            filter: ep
1223                .traffic_filter
1224                .as_ref()
1225                .map(convert_source_traffic_filter),
1226        }
1227    } else {
1228        // Fallback: extract zone ID from flat field
1229        let zone_id = flat_zone_id
1230            .and_then(|v| v.as_str())
1231            .and_then(|s| uuid::Uuid::parse_str(s).ok())
1232            .map(EntityId::Uuid);
1233        PolicyEndpoint {
1234            zone_id,
1235            filter: None,
1236        }
1237    }
1238}
1239
1240// Overload for destination (different filter enum)
1241fn convert_dest_policy_endpoint(
1242    endpoint: Option<&integration_types::FirewallPolicyDestination>,
1243    flat_zone_id: Option<&serde_json::Value>,
1244) -> PolicyEndpoint {
1245    if let Some(ep) = endpoint {
1246        PolicyEndpoint {
1247            zone_id: ep.zone_id.map(EntityId::Uuid),
1248            filter: ep.traffic_filter.as_ref().map(convert_dest_traffic_filter),
1249        }
1250    } else {
1251        let zone_id = flat_zone_id
1252            .and_then(|v| v.as_str())
1253            .and_then(|s| uuid::Uuid::parse_str(s).ok())
1254            .map(EntityId::Uuid);
1255        PolicyEndpoint {
1256            zone_id,
1257            filter: None,
1258        }
1259    }
1260}
1261
1262fn convert_source_traffic_filter(f: &integration_types::SourceTrafficFilter) -> TrafficFilter {
1263    use integration_types::SourceTrafficFilter as S;
1264    match f {
1265        S::Network {
1266            network_filter,
1267            mac_address_filter,
1268            port_filter,
1269        } => TrafficFilter::Network {
1270            network_ids: network_filter
1271                .network_ids
1272                .iter()
1273                .copied()
1274                .map(EntityId::Uuid)
1275                .collect(),
1276            match_opposite: network_filter.match_opposite,
1277            mac_addresses: mac_address_filter
1278                .as_ref()
1279                .map(|m| m.mac_addresses.clone())
1280                .unwrap_or_default(),
1281            ports: port_filter.as_ref().map(convert_port_filter),
1282        },
1283        S::IpAddress {
1284            ip_address_filter,
1285            mac_address_filter,
1286            port_filter,
1287        } => TrafficFilter::IpAddress {
1288            addresses: convert_ip_address_filter(ip_address_filter),
1289            match_opposite: ip_filter_match_opposite(ip_address_filter),
1290            mac_addresses: mac_address_filter
1291                .as_ref()
1292                .map(|m| m.mac_addresses.clone())
1293                .unwrap_or_default(),
1294            ports: port_filter.as_ref().map(convert_port_filter),
1295        },
1296        S::MacAddress {
1297            mac_address_filter,
1298            port_filter,
1299        } => TrafficFilter::MacAddress {
1300            mac_addresses: mac_address_filter.mac_addresses.clone(),
1301            ports: port_filter.as_ref().map(convert_port_filter),
1302        },
1303        S::Port { port_filter } => TrafficFilter::Port {
1304            ports: convert_port_filter(port_filter),
1305        },
1306        S::Region {
1307            region_filter,
1308            port_filter,
1309        } => TrafficFilter::Region {
1310            regions: region_filter.regions.clone(),
1311            ports: port_filter.as_ref().map(convert_port_filter),
1312        },
1313        S::Unknown => TrafficFilter::Other {
1314            raw_type: "UNKNOWN".into(),
1315        },
1316    }
1317}
1318
1319fn convert_dest_traffic_filter(f: &integration_types::DestTrafficFilter) -> TrafficFilter {
1320    use integration_types::DestTrafficFilter as D;
1321    match f {
1322        D::Network {
1323            network_filter,
1324            port_filter,
1325        } => TrafficFilter::Network {
1326            network_ids: network_filter
1327                .network_ids
1328                .iter()
1329                .copied()
1330                .map(EntityId::Uuid)
1331                .collect(),
1332            match_opposite: network_filter.match_opposite,
1333            mac_addresses: Vec::new(),
1334            ports: port_filter.as_ref().map(convert_port_filter),
1335        },
1336        D::IpAddress {
1337            ip_address_filter,
1338            port_filter,
1339        } => TrafficFilter::IpAddress {
1340            addresses: convert_ip_address_filter(ip_address_filter),
1341            match_opposite: ip_filter_match_opposite(ip_address_filter),
1342            mac_addresses: Vec::new(),
1343            ports: port_filter.as_ref().map(convert_port_filter),
1344        },
1345        D::Port { port_filter } => TrafficFilter::Port {
1346            ports: convert_port_filter(port_filter),
1347        },
1348        D::Region {
1349            region_filter,
1350            port_filter,
1351        } => TrafficFilter::Region {
1352            regions: region_filter.regions.clone(),
1353            ports: port_filter.as_ref().map(convert_port_filter),
1354        },
1355        D::Application {
1356            application_filter,
1357            port_filter,
1358        } => TrafficFilter::Application {
1359            application_ids: application_filter.application_ids.clone(),
1360            ports: port_filter.as_ref().map(convert_port_filter),
1361        },
1362        D::ApplicationCategory {
1363            application_category_filter,
1364            port_filter,
1365        } => TrafficFilter::ApplicationCategory {
1366            category_ids: application_category_filter.application_category_ids.clone(),
1367            ports: port_filter.as_ref().map(convert_port_filter),
1368        },
1369        D::Domain {
1370            domain_filter,
1371            port_filter,
1372        } => {
1373            let domains = match domain_filter {
1374                integration_types::DomainFilter::Specific { domains } => domains.clone(),
1375                integration_types::DomainFilter::Unknown => Vec::new(),
1376            };
1377            TrafficFilter::Domain {
1378                domains,
1379                ports: port_filter.as_ref().map(convert_port_filter),
1380            }
1381        }
1382        D::Unknown => TrafficFilter::Other {
1383            raw_type: "UNKNOWN".into(),
1384        },
1385    }
1386}
1387
1388fn convert_port_filter(pf: &integration_types::PortFilter) -> PortSpec {
1389    match pf {
1390        integration_types::PortFilter::Ports {
1391            items,
1392            match_opposite,
1393        } => PortSpec::Values {
1394            items: items
1395                .iter()
1396                .map(|item| match item {
1397                    integration_types::PortItem::Number { value } => value.clone(),
1398                    integration_types::PortItem::Range {
1399                        start_port,
1400                        end_port,
1401                    } => format!("{start_port}-{end_port}"),
1402                    integration_types::PortItem::Unknown => "?".into(),
1403                })
1404                .collect(),
1405            match_opposite: *match_opposite,
1406        },
1407        integration_types::PortFilter::TrafficMatchingList {
1408            traffic_matching_list_id,
1409            match_opposite,
1410        } => PortSpec::MatchingList {
1411            list_id: EntityId::Uuid(*traffic_matching_list_id),
1412            match_opposite: *match_opposite,
1413        },
1414        integration_types::PortFilter::Unknown => PortSpec::Values {
1415            items: Vec::new(),
1416            match_opposite: false,
1417        },
1418    }
1419}
1420
1421fn convert_ip_address_filter(f: &integration_types::IpAddressFilter) -> Vec<IpSpec> {
1422    match f {
1423        integration_types::IpAddressFilter::Specific { items, .. } => items
1424            .iter()
1425            .map(|item| match item {
1426                integration_types::IpAddressItem::Address { value } => IpSpec::Address {
1427                    value: value.clone(),
1428                },
1429                integration_types::IpAddressItem::Range { start, stop } => IpSpec::Range {
1430                    start: start.clone(),
1431                    stop: stop.clone(),
1432                },
1433                integration_types::IpAddressItem::Subnet { value } => IpSpec::Subnet {
1434                    value: value.clone(),
1435                },
1436            })
1437            .collect(),
1438        integration_types::IpAddressFilter::TrafficMatchingList {
1439            traffic_matching_list_id,
1440            ..
1441        } => vec![IpSpec::MatchingList {
1442            list_id: EntityId::Uuid(*traffic_matching_list_id),
1443        }],
1444        integration_types::IpAddressFilter::Unknown => Vec::new(),
1445    }
1446}
1447
1448fn ip_filter_match_opposite(f: &integration_types::IpAddressFilter) -> bool {
1449    match f {
1450        integration_types::IpAddressFilter::Specific { match_opposite, .. }
1451        | integration_types::IpAddressFilter::TrafficMatchingList { match_opposite, .. } => {
1452            *match_opposite
1453        }
1454        integration_types::IpAddressFilter::Unknown => false,
1455    }
1456}
1457
1458// ── Firewall Zone ────────────────────────────────────────────────
1459
1460impl From<integration_types::FirewallZoneResponse> for FirewallZone {
1461    fn from(z: integration_types::FirewallZoneResponse) -> Self {
1462        FirewallZone {
1463            id: EntityId::Uuid(z.id),
1464            name: z.name,
1465            network_ids: z.network_ids.into_iter().map(EntityId::Uuid).collect(),
1466            origin: origin_from_metadata(&z.metadata),
1467            source: DataSource::IntegrationApi,
1468        }
1469    }
1470}
1471
1472// ── ACL Rule ─────────────────────────────────────────────────────
1473
1474impl From<integration_types::AclRuleResponse> for AclRule {
1475    fn from(r: integration_types::AclRuleResponse) -> Self {
1476        let rule_type = match r.rule_type.as_str() {
1477            "MAC" => AclRuleType::Mac,
1478            _ => AclRuleType::Ipv4,
1479        };
1480
1481        let action = match r.action.as_str() {
1482            "ALLOW" => AclAction::Allow,
1483            _ => AclAction::Block,
1484        };
1485
1486        AclRule {
1487            id: EntityId::Uuid(r.id),
1488            name: r.name,
1489            enabled: r.enabled,
1490            rule_type,
1491            action,
1492            source_summary: None,
1493            destination_summary: None,
1494            origin: origin_from_metadata(&r.metadata),
1495            source: DataSource::IntegrationApi,
1496        }
1497    }
1498}
1499
1500// ── DNS Policy ───────────────────────────────────────────────────
1501
1502impl From<integration_types::DnsPolicyResponse> for DnsPolicy {
1503    fn from(d: integration_types::DnsPolicyResponse) -> Self {
1504        let policy_type = match d.policy_type.as_str() {
1505            "A" => DnsPolicyType::ARecord,
1506            "AAAA" => DnsPolicyType::AaaaRecord,
1507            "CNAME" => DnsPolicyType::CnameRecord,
1508            "MX" => DnsPolicyType::MxRecord,
1509            "TXT" => DnsPolicyType::TxtRecord,
1510            "SRV" => DnsPolicyType::SrvRecord,
1511            _ => DnsPolicyType::ForwardDomain,
1512        };
1513
1514        DnsPolicy {
1515            id: EntityId::Uuid(d.id),
1516            policy_type,
1517            domain: d.domain.unwrap_or_default(),
1518            value: dns_value_from_extra(policy_type, &d.extra),
1519            #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1520            ttl_seconds: d
1521                .extra
1522                .get("ttlSeconds")
1523                .and_then(serde_json::Value::as_u64)
1524                .map(|t| t as u32),
1525            origin: origin_from_metadata(&d.metadata),
1526            source: DataSource::IntegrationApi,
1527        }
1528    }
1529}
1530
1531// ── Traffic Matching List ────────────────────────────────────────
1532
1533impl From<integration_types::TrafficMatchingListResponse> for TrafficMatchingList {
1534    fn from(t: integration_types::TrafficMatchingListResponse) -> Self {
1535        let items = t
1536            .extra
1537            .get("items")
1538            .and_then(|v| v.as_array())
1539            .map(|arr| {
1540                arr.iter()
1541                    .filter_map(traffic_matching_item_to_string)
1542                    .collect()
1543            })
1544            .unwrap_or_default();
1545
1546        TrafficMatchingList {
1547            id: EntityId::Uuid(t.id),
1548            name: t.name,
1549            list_type: t.list_type,
1550            items,
1551            origin: None,
1552        }
1553    }
1554}
1555
1556// ── Voucher ──────────────────────────────────────────────────────
1557
1558impl From<integration_types::VoucherResponse> for Voucher {
1559    fn from(v: integration_types::VoucherResponse) -> Self {
1560        #[allow(
1561            clippy::as_conversions,
1562            clippy::cast_possible_truncation,
1563            clippy::cast_sign_loss
1564        )]
1565        Voucher {
1566            id: EntityId::Uuid(v.id),
1567            code: v.code,
1568            name: Some(v.name),
1569            created_at: parse_iso(&v.created_at),
1570            activated_at: v.activated_at.as_deref().and_then(parse_iso),
1571            expires_at: v.expires_at.as_deref().and_then(parse_iso),
1572            expired: v.expired,
1573            time_limit_minutes: Some(v.time_limit_minutes as u32),
1574            data_usage_limit_mb: v.data_usage_limit_m_bytes.map(|b| b as u64),
1575            authorized_guest_limit: v.authorized_guest_limit.map(|l| l as u32),
1576            authorized_guest_count: Some(v.authorized_guest_count as u32),
1577            rx_rate_limit_kbps: v.rx_rate_limit_kbps.map(|r| r as u64),
1578            tx_rate_limit_kbps: v.tx_rate_limit_kbps.map(|r| r as u64),
1579            source: DataSource::IntegrationApi,
1580        }
1581    }
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586    use super::*;
1587    use serde_json::json;
1588
1589    #[test]
1590    fn device_type_from_legacy_type_field() {
1591        assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
1592        assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
1593        assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
1594        assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
1595    }
1596
1597    #[test]
1598    fn device_type_from_model_fallback() {
1599        assert_eq!(
1600            infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
1601            DeviceType::AccessPoint
1602        );
1603        assert_eq!(
1604            infer_device_type("unknown", Some(&"U6-LR".into())),
1605            DeviceType::AccessPoint
1606        );
1607        assert_eq!(
1608            infer_device_type("unknown", Some(&"USW-24-PoE".into())),
1609            DeviceType::Switch
1610        );
1611        assert_eq!(
1612            infer_device_type("unknown", Some(&"UDM-Pro".into())),
1613            DeviceType::Gateway
1614        );
1615        assert_eq!(
1616            infer_device_type("unknown", Some(&"UCG-Max".into())),
1617            DeviceType::Gateway
1618        );
1619    }
1620
1621    #[test]
1622    fn integration_device_type_gateway_by_model() {
1623        // UCG Max has "switching" but not "routing" — should still be Gateway
1624        assert_eq!(
1625            infer_device_type_integration(&["switching".into()], "UCG-Max"),
1626            DeviceType::Gateway
1627        );
1628        // UDM with both features
1629        assert_eq!(
1630            infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
1631            DeviceType::Gateway
1632        );
1633    }
1634
1635    #[test]
1636    fn device_state_mapping() {
1637        assert_eq!(map_device_state(0), DeviceState::Offline);
1638        assert_eq!(map_device_state(1), DeviceState::Online);
1639        assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
1640        assert_eq!(map_device_state(4), DeviceState::Updating);
1641        assert_eq!(map_device_state(5), DeviceState::GettingReady);
1642        assert_eq!(map_device_state(99), DeviceState::Unknown);
1643    }
1644
1645    #[test]
1646    fn legacy_site_uses_desc_as_display_name() {
1647        let site = LegacySite {
1648            id: "abc123".into(),
1649            name: "default".into(),
1650            desc: Some("Main Office".into()),
1651            role: None,
1652            extra: serde_json::Map::new(),
1653        };
1654        let converted: Site = site.into();
1655        assert_eq!(converted.internal_name, "default");
1656        assert_eq!(converted.name, "Main Office");
1657    }
1658
1659    #[test]
1660    fn legacy_site_falls_back_to_name_when_desc_empty() {
1661        let site = LegacySite {
1662            id: "abc123".into(),
1663            name: "branch-1".into(),
1664            desc: Some(String::new()),
1665            role: None,
1666            extra: serde_json::Map::new(),
1667        };
1668        let converted: Site = site.into();
1669        assert_eq!(converted.name, "branch-1");
1670    }
1671
1672    #[test]
1673    fn event_category_mapping() {
1674        assert_eq!(
1675            map_event_category(Some(&"wlan".into())),
1676            EventCategory::Network
1677        );
1678        assert_eq!(
1679            map_event_category(Some(&"device".into())),
1680            EventCategory::Device
1681        );
1682        assert_eq!(
1683            map_event_category(Some(&"admin".into())),
1684            EventCategory::Admin
1685        );
1686        assert_eq!(map_event_category(None), EventCategory::Unknown);
1687    }
1688
1689    #[test]
1690    fn channel_frequency_bands() {
1691        assert_eq!(channel_to_frequency(Some(6)), Some(2.4));
1692        assert_eq!(channel_to_frequency(Some(36)), Some(5.0));
1693        assert_eq!(channel_to_frequency(Some(149)), Some(5.0));
1694        assert_eq!(channel_to_frequency(None), None);
1695    }
1696
1697    #[test]
1698    fn integration_wifi_broadcast_preserves_standard_fields() {
1699        let response = integration_types::WifiBroadcastResponse {
1700            id: uuid::Uuid::nil(),
1701            name: "Main".into(),
1702            broadcast_type: "STANDARD".into(),
1703            enabled: true,
1704            security_configuration: json!({"mode": "WPA2_PERSONAL"}),
1705            metadata: json!({"origin": "USER"}),
1706            network: Some(json!({"id": uuid::Uuid::nil().to_string()})),
1707            broadcasting_device_filter: None,
1708            extra: HashMap::from([
1709                ("broadcastingFrequenciesGHz".into(), json!([2.4, 5.0])),
1710                ("hideName".into(), json!(true)),
1711                ("clientIsolationEnabled".into(), json!(true)),
1712                ("bandSteeringEnabled".into(), json!(true)),
1713                ("mloEnabled".into(), json!(false)),
1714                ("bssTransitionEnabled".into(), json!(true)),
1715                (
1716                    "hotspotConfiguration".into(),
1717                    json!({"type": "CAPTIVE_PORTAL"}),
1718                ),
1719            ]),
1720        };
1721
1722        let wifi = WifiBroadcast::from(response);
1723        assert_eq!(wifi.frequencies_ghz.len(), 2);
1724        assert!((wifi.frequencies_ghz[0] - 2.4).abs() < f32::EPSILON);
1725        assert!((wifi.frequencies_ghz[1] - 5.0).abs() < f32::EPSILON);
1726        assert!(wifi.hidden);
1727        assert!(wifi.client_isolation);
1728        assert!(wifi.band_steering);
1729        assert!(wifi.fast_roaming);
1730        assert!(wifi.hotspot_enabled);
1731    }
1732
1733    #[test]
1734    fn integration_dns_policy_uses_type_specific_fields() {
1735        let response = integration_types::DnsPolicyResponse {
1736            id: uuid::Uuid::nil(),
1737            policy_type: "A".into(),
1738            enabled: true,
1739            domain: Some("example.com".into()),
1740            metadata: json!({"origin": "USER"}),
1741            extra: HashMap::from([
1742                ("ipv4Address".into(), json!("192.168.1.10")),
1743                ("ttlSeconds".into(), json!(600)),
1744            ]),
1745        };
1746
1747        let dns = DnsPolicy::from(response);
1748        assert_eq!(dns.value, "192.168.1.10");
1749        assert_eq!(dns.ttl_seconds, Some(600));
1750    }
1751
1752    #[test]
1753    fn integration_traffic_matching_list_formats_structured_items() {
1754        let response = integration_types::TrafficMatchingListResponse {
1755            id: uuid::Uuid::nil(),
1756            name: "Ports".into(),
1757            list_type: "PORT".into(),
1758            extra: HashMap::from([(
1759                "items".into(),
1760                json!([
1761                    {"type": "PORT_NUMBER", "value": 443},
1762                    {"type": "PORT_RANGE", "start": 1000, "stop": 2000}
1763                ]),
1764            )]),
1765        };
1766
1767        let list = TrafficMatchingList::from(response);
1768        assert_eq!(list.items, vec!["443".to_owned(), "1000-2000".to_owned()]);
1769    }
1770}