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::session::models::{
15    SessionAlarm, SessionClientEntry, SessionDevice, SessionEvent, SessionSite,
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        NatPolicy, NatType, 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 session 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 session `type` field and optional `model` string.
224///
225/// The session 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 session API 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<SessionDevice> for Device {
273    fn from(d: SessionDevice) -> 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, // Session 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, // Session 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::SessionApi,
327            updated_at: Utc::now(),
328        }
329    }
330}
331
332// ── Client ─────────────────────────────────────────────────────────
333
334impl From<SessionClientEntry> for Client {
335    fn from(c: SessionClientEntry) -> 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::SessionApi,
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<SessionSite> for Site {
429    fn from(s: SessionSite) -> 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::SessionApi,
443        }
444    }
445}
446
447// ── Event ──────────────────────────────────────────────────────────
448
449/// Map session API 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<SessionEvent> for Event {
464    fn from(e: SessionEvent) -> 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: resolve_event_templates(
472                &e.msg.unwrap_or_default(),
473                &serde_json::Value::Object(e.extra),
474            ),
475            device_mac: None,
476            client_mac: None,
477            site_id: e.site_id.map(EntityId::from),
478            raw_key: e.key,
479            source: DataSource::SessionApi,
480        }
481    }
482}
483
484// ── Alarm → Event ──────────────────────────────────────────────────
485
486impl From<SessionAlarm> for Event {
487    fn from(a: SessionAlarm) -> Self {
488        Event {
489            id: Some(EntityId::from(a.id)),
490            timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
491            category: EventCategory::System,
492            severity: EventSeverity::Warning,
493            event_type: a.key.clone().unwrap_or_default(),
494            message: a.msg.unwrap_or_default(),
495            device_mac: None,
496            client_mac: None,
497            site_id: None,
498            raw_key: a.key,
499            source: DataSource::SessionApi,
500        }
501    }
502}
503
504impl From<SessionAlarm> for Alarm {
505    fn from(a: SessionAlarm) -> Self {
506        Alarm {
507            id: EntityId::from(a.id),
508            timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
509            category: EventCategory::System,
510            severity: EventSeverity::Warning,
511            message: a.msg.unwrap_or_default(),
512            archived: a.archived.unwrap_or(false),
513            device_mac: None,
514            site_id: None,
515        }
516    }
517}
518
519// ── WebSocket Event ──────────────────────────────────────────────
520
521/// Infer severity from a WebSocket event key.
522///
523/// Disconnect/Lost/Down keywords → Warning, Error/Fail → Error, else Info.
524fn infer_ws_severity(key: &str) -> EventSeverity {
525    let upper = key.to_uppercase();
526    if upper.contains("ERROR") || upper.contains("FAIL") {
527        EventSeverity::Error
528    } else if upper.contains("DISCONNECT") || upper.contains("LOST") || upper.contains("DOWN") {
529        EventSeverity::Warning
530    } else {
531        EventSeverity::Info
532    }
533}
534
535impl From<UnifiEvent> for Event {
536    fn from(e: UnifiEvent) -> Self {
537        let category = map_event_category(Some(&e.subsystem));
538        let severity = infer_ws_severity(&e.key);
539
540        // Extract device MAC from common extra fields
541        let device_mac = e
542            .extra
543            .get("mac")
544            .or_else(|| e.extra.get("sw"))
545            .or_else(|| e.extra.get("ap"))
546            .and_then(|v| v.as_str())
547            .map(MacAddress::new);
548
549        // Extract client MAC from common extra fields
550        let client_mac = e
551            .extra
552            .get("user")
553            .or_else(|| e.extra.get("sta"))
554            .and_then(|v| v.as_str())
555            .map(MacAddress::new);
556
557        let site_id = if e.site_id.is_empty() {
558            None
559        } else {
560            Some(EntityId::Legacy(e.site_id))
561        };
562
563        Event {
564            id: None,
565            timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
566            category,
567            severity,
568            event_type: e.key.clone(),
569            message: resolve_event_templates(&e.message.unwrap_or_default(), &e.extra),
570            device_mac,
571            client_mac,
572            site_id,
573            raw_key: Some(e.key),
574            source: DataSource::SessionApi,
575        }
576    }
577}
578
579// ━━ Integration API conversions ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
580
581// ── Helpers ────────────────────────────────────────────────────────
582
583/// Resolve `{placeholder}` templates in event messages using extra fields.
584///
585/// The UniFi controller sometimes sends raw templates like
586/// `User[{user}] disconnected from "{ssid}"` instead of resolving them.
587/// We fill them in from the event's extra JSON fields.
588fn resolve_event_templates(msg: &str, extra: &serde_json::Value) -> String {
589    if !msg.contains('{') {
590        return msg.to_string();
591    }
592
593    let mut result = msg.to_string();
594    // Find all {key} placeholders and replace with extra field values
595    while let Some(start) = result.find('{') {
596        let Some(end) = result[start..].find('}') else {
597            break;
598        };
599        let key = &result[start + 1..start + end];
600        let replacement = extra
601            .get(key)
602            .and_then(|v| match v {
603                serde_json::Value::String(s) => Some(s.as_str()),
604                _ => None,
605            })
606            .unwrap_or(key);
607        result = format!(
608            "{}{replacement}{}",
609            &result[..start],
610            &result[start + end + 1..]
611        );
612    }
613    result
614}
615
616/// Parse an ISO-8601 string (Integration API format) to `DateTime<Utc>`.
617fn parse_iso(raw: &str) -> Option<DateTime<Utc>> {
618    DateTime::parse_from_rfc3339(raw)
619        .ok()
620        .map(|dt| dt.with_timezone(&Utc))
621}
622
623/// Map Integration API management string to `EntityOrigin`.
624fn map_origin(management: &str) -> Option<EntityOrigin> {
625    match management {
626        "USER_DEFINED" => Some(EntityOrigin::UserDefined),
627        "SYSTEM_DEFINED" => Some(EntityOrigin::SystemDefined),
628        "ORCHESTRATED" => Some(EntityOrigin::Orchestrated),
629        _ => None,
630    }
631}
632
633/// Extract origin from a `metadata` JSON object.
634///
635/// Checks `metadata.origin` (real API) and `metadata.management` (spec)
636/// since the field name varies by firmware version.
637fn origin_from_metadata(metadata: &serde_json::Value) -> Option<EntityOrigin> {
638    metadata
639        .get("origin")
640        .or_else(|| metadata.get("management"))
641        .and_then(|v| v.as_str())
642        .and_then(map_origin)
643}
644
645/// Map Integration API device state string to `DeviceState`.
646fn map_integration_device_state(state: &str) -> DeviceState {
647    match state {
648        "ONLINE" => DeviceState::Online,
649        "OFFLINE" => DeviceState::Offline,
650        "PENDING_ADOPTION" => DeviceState::PendingAdoption,
651        "UPDATING" => DeviceState::Updating,
652        "GETTING_READY" => DeviceState::GettingReady,
653        "ADOPTING" => DeviceState::Adopting,
654        "DELETING" => DeviceState::Deleting,
655        "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
656        "ISOLATED" => DeviceState::Isolated,
657        _ => DeviceState::Unknown,
658    }
659}
660
661/// Infer `DeviceType` from Integration API `features` list and `model` string.
662fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
663    let has = |f: &str| features.iter().any(|s| s == f);
664
665    // Check model prefix first — some gateways (UCG Max) report "switching"
666    // without "routing", which would misclassify them as switches.
667    let upper = model.to_uppercase();
668    let is_gateway_model = upper.starts_with("UGW")
669        || upper.starts_with("UDM")
670        || upper.starts_with("UDR")
671        || upper.starts_with("UXG")
672        || upper.starts_with("UCG")
673        || upper.starts_with("UCK");
674
675    if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
676        DeviceType::Gateway
677    } else if has("accessPoint") {
678        DeviceType::AccessPoint
679    } else if has("switching") {
680        DeviceType::Switch
681    } else {
682        // Fallback to model prefix
683        let model_owned = model.to_owned();
684        infer_device_type("", Some(&model_owned))
685    }
686}
687
688// ── Device ────────────────────────────────────────────────────────
689
690impl From<integration_types::DeviceResponse> for Device {
691    fn from(d: integration_types::DeviceResponse) -> Self {
692        let device_type = infer_device_type_integration(&d.features, &d.model);
693        let state = map_integration_device_state(&d.state);
694
695        Device {
696            id: EntityId::Uuid(d.id),
697            mac: MacAddress::new(&d.mac_address),
698            ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
699            wan_ipv6: None,
700            name: Some(d.name),
701            model: Some(d.model),
702            device_type,
703            state,
704            firmware_version: d.firmware_version,
705            firmware_updatable: d.firmware_updatable,
706            adopted_at: None,
707            provisioned_at: None,
708            last_seen: None,
709            serial: None,
710            supported: d.supported,
711            ports: Vec::new(),
712            radios: Vec::new(),
713            uplink_device_id: None,
714            uplink_device_mac: None,
715            has_switching: d.features.iter().any(|f| f == "switching"),
716            has_access_point: d.features.iter().any(|f| f == "accessPoint"),
717            stats: DeviceStats::default(),
718            client_count: None,
719            origin: None,
720            source: DataSource::IntegrationApi,
721            updated_at: Utc::now(),
722        }
723    }
724}
725
726/// Convert Integration API device statistics into domain `DeviceStats`.
727pub(crate) fn device_stats_from_integration(
728    resp: &integration_types::DeviceStatisticsResponse,
729) -> DeviceStats {
730    DeviceStats {
731        uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
732        cpu_utilization_pct: resp.cpu_utilization_pct,
733        memory_utilization_pct: resp.memory_utilization_pct,
734        load_average_1m: resp.load_average_1_min,
735        load_average_5m: resp.load_average_5_min,
736        load_average_15m: resp.load_average_15_min,
737        last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
738        next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
739        uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
740            let tx = u
741                .get("txRateBps")
742                .or_else(|| u.get("txBytesPerSecond"))
743                .or_else(|| u.get("tx_bytes-r"))
744                .and_then(serde_json::Value::as_u64)
745                .unwrap_or(0);
746            let rx = u
747                .get("rxRateBps")
748                .or_else(|| u.get("rxBytesPerSecond"))
749                .or_else(|| u.get("rx_bytes-r"))
750                .and_then(serde_json::Value::as_u64)
751                .unwrap_or(0);
752            if tx == 0 && rx == 0 {
753                None
754            } else {
755                Some(Bandwidth {
756                    tx_bytes_per_sec: tx,
757                    rx_bytes_per_sec: rx,
758                })
759            }
760        }),
761    }
762}
763
764// ── Client ────────────────────────────────────────────────────────
765
766impl From<integration_types::ClientResponse> for Client {
767    fn from(c: integration_types::ClientResponse) -> Self {
768        let client_type = match c.client_type.as_str() {
769            "WIRED" => ClientType::Wired,
770            "WIRELESS" => ClientType::Wireless,
771            "VPN" => ClientType::Vpn,
772            "TELEPORT" => ClientType::Teleport,
773            _ => ClientType::Unknown,
774        };
775
776        // MAC is a top-level field for wired/wireless clients; fall back
777        // to UUID so VPN/teleport clients still get unique store keys.
778        let uuid_fallback = c.id.to_string();
779        let mac_str = c
780            .mac_address
781            .as_deref()
782            .filter(|s| !s.is_empty())
783            .unwrap_or(&uuid_fallback);
784
785        Client {
786            id: EntityId::Uuid(c.id),
787            mac: MacAddress::new(mac_str),
788            ip: c.ip_address.as_deref().and_then(|s| s.parse().ok()),
789            name: Some(c.name),
790            hostname: None,
791            client_type,
792            connected_at: c.connected_at.as_deref().and_then(parse_iso),
793            uplink_device_id: None,
794            uplink_device_mac: None,
795            network_id: None,
796            vlan: None,
797            wireless: None,
798            guest_auth: None,
799            is_guest: false,
800            tx_bytes: None,
801            rx_bytes: None,
802            bandwidth: None,
803            os_name: None,
804            device_class: None,
805            use_fixedip: false,
806            fixed_ip: None,
807            blocked: false,
808            source: DataSource::IntegrationApi,
809            updated_at: Utc::now(),
810        }
811    }
812}
813
814// ── Site ──────────────────────────────────────────────────────────
815
816impl From<integration_types::SiteResponse> for Site {
817    fn from(s: integration_types::SiteResponse) -> Self {
818        Site {
819            id: EntityId::Uuid(s.id),
820            internal_name: s.internal_reference,
821            name: s.name,
822            device_count: None,
823            client_count: None,
824            source: DataSource::IntegrationApi,
825        }
826    }
827}
828
829// ── Network ──────────────────────────────────────────────────────
830
831/// Look up a field in `extra` first, then fall back to `metadata`.
832fn net_field<'a>(
833    extra: &'a HashMap<String, Value>,
834    metadata: &'a Value,
835    key: &str,
836) -> Option<&'a Value> {
837    extra.get(key).or_else(|| metadata.get(key))
838}
839
840/// Parse network configuration from API extra/metadata fields into a `Network`.
841#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
842fn parse_network_fields(
843    id: uuid::Uuid,
844    name: String,
845    enabled: bool,
846    management_str: &str,
847    vlan_id: i32,
848    is_default: bool,
849    metadata: &Value,
850    extra: &HashMap<String, Value>,
851) -> Network {
852    // ── Feature flags ───────────────────────────────────────────
853    let isolation_enabled = net_field(extra, metadata, "isolationEnabled")
854        .and_then(Value::as_bool)
855        .unwrap_or(false);
856    let internet_access_enabled = net_field(extra, metadata, "internetAccessEnabled")
857        .and_then(Value::as_bool)
858        .unwrap_or(true);
859    let mdns_forwarding_enabled = net_field(extra, metadata, "mdnsForwardingEnabled")
860        .and_then(Value::as_bool)
861        .unwrap_or(false);
862    let cellular_backup_enabled = net_field(extra, metadata, "cellularBackupEnabled")
863        .and_then(Value::as_bool)
864        .unwrap_or(false);
865
866    // ── Firewall zone ───────────────────────────────────────────
867    let firewall_zone_id = net_field(extra, metadata, "zoneId")
868        .and_then(Value::as_str)
869        .and_then(|s| uuid::Uuid::parse_str(s).ok())
870        .map(EntityId::Uuid);
871
872    // ── IPv4 configuration ──────────────────────────────────────
873    // Detail API uses: hostIpAddress, prefixLength, dhcpConfiguration
874    // Some firmware uses: host, prefix, dhcp.server
875    let ipv4 = net_field(extra, metadata, "ipv4Configuration");
876
877    let gateway_ip: Option<Ipv4Addr> = ipv4
878        .and_then(|v| v.get("hostIpAddress").or_else(|| v.get("host")))
879        .and_then(Value::as_str)
880        .and_then(|s| s.parse().ok());
881
882    let subnet = ipv4.and_then(|v| {
883        let host = v.get("hostIpAddress").or_else(|| v.get("host"))?.as_str()?;
884        let prefix = v
885            .get("prefixLength")
886            .or_else(|| v.get("prefix"))?
887            .as_u64()?;
888        Some(format!("{host}/{prefix}"))
889    });
890
891    // ── DHCP ────────────────────────────────────────────────────
892    // Detail API: dhcpConfiguration.mode/leaseTimeSeconds/ipAddressRange/dnsServerIpAddressesOverride
893    // Fallback:   dhcp.server.enabled/rangeStart/rangeStop/leaseTimeSec/dnsOverride.servers
894    let dhcp = ipv4.and_then(|v| {
895        // Try new-style dhcpConfiguration first
896        if let Some(dhcp_cfg) = v.get("dhcpConfiguration") {
897            let mode = dhcp_cfg.get("mode").and_then(Value::as_str).unwrap_or("");
898            let dhcp_enabled = mode == "SERVER";
899            let range = dhcp_cfg.get("ipAddressRange");
900            let range_start = range
901                .and_then(|r| r.get("start").or_else(|| r.get("rangeStart")))
902                .and_then(Value::as_str)
903                .and_then(|s| s.parse().ok());
904            let range_stop = range
905                .and_then(|r| r.get("end").or_else(|| r.get("rangeStop")))
906                .and_then(Value::as_str)
907                .and_then(|s| s.parse().ok());
908            let lease_time_secs = dhcp_cfg.get("leaseTimeSeconds").and_then(Value::as_u64);
909            let dns_servers = dhcp_cfg
910                .get("dnsServerIpAddressesOverride")
911                .and_then(Value::as_array)
912                .map(|arr| {
913                    arr.iter()
914                        .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
915                        .collect()
916                })
917                .unwrap_or_default();
918            return Some(DhcpConfig {
919                enabled: dhcp_enabled,
920                range_start,
921                range_stop,
922                lease_time_secs,
923                dns_servers,
924                gateway: gateway_ip,
925            });
926        }
927
928        // Fallback: old-style dhcp.server
929        let server = v.get("dhcp")?.get("server")?;
930        let dhcp_enabled = server
931            .get("enabled")
932            .and_then(Value::as_bool)
933            .unwrap_or(false);
934        let range_start = server
935            .get("rangeStart")
936            .and_then(Value::as_str)
937            .and_then(|s| s.parse().ok());
938        let range_stop = server
939            .get("rangeStop")
940            .and_then(Value::as_str)
941            .and_then(|s| s.parse().ok());
942        let lease_time_secs = server.get("leaseTimeSec").and_then(Value::as_u64);
943        let dns_servers = server
944            .get("dnsOverride")
945            .and_then(|d| d.get("servers"))
946            .and_then(Value::as_array)
947            .map(|arr| {
948                arr.iter()
949                    .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
950                    .collect()
951            })
952            .unwrap_or_default();
953        let gateway = server
954            .get("gateway")
955            .and_then(Value::as_str)
956            .and_then(|s| s.parse().ok())
957            .or(gateway_ip);
958        Some(DhcpConfig {
959            enabled: dhcp_enabled,
960            range_start,
961            range_stop,
962            lease_time_secs,
963            dns_servers,
964            gateway,
965        })
966    });
967
968    // ── PXE / NTP / TFTP ────────────────────────────────────────
969    let pxe_enabled = ipv4
970        .and_then(|v| v.get("pxe"))
971        .and_then(|v| v.get("enabled"))
972        .and_then(Value::as_bool)
973        .unwrap_or(false);
974    let ntp_server = ipv4
975        .and_then(|v| v.get("ntp"))
976        .and_then(|v| v.get("server"))
977        .and_then(Value::as_str)
978        .and_then(|s| s.parse::<IpAddr>().ok());
979    let tftp_server = ipv4
980        .and_then(|v| v.get("tftp"))
981        .and_then(|v| v.get("server"))
982        .and_then(Value::as_str)
983        .map(String::from);
984
985    // ── IPv6 ────────────────────────────────────────────────────
986    // Detail API: interfaceType, clientAddressAssignment.slaacEnabled, additionalHostIpSubnets
987    // Fallback:   type, slaac.enabled, dhcpv6.enabled, prefix
988    let ipv6 = net_field(extra, metadata, "ipv6Configuration");
989    let ipv6_enabled = ipv6.is_some();
990    let ipv6_mode = ipv6
991        .and_then(|v| v.get("interfaceType").or_else(|| v.get("type")))
992        .and_then(Value::as_str)
993        .and_then(|s| match s {
994            "PREFIX_DELEGATION" => Some(Ipv6Mode::PrefixDelegation),
995            "STATIC" => Some(Ipv6Mode::Static),
996            _ => None,
997        });
998    let slaac_enabled = ipv6
999        .and_then(|v| {
1000            // New: clientAddressAssignment.slaacEnabled
1001            v.get("clientAddressAssignment")
1002                .and_then(|ca| ca.get("slaacEnabled"))
1003                .and_then(Value::as_bool)
1004                // Fallback: slaac.enabled
1005                .or_else(|| v.get("slaac").and_then(|s| s.get("enabled")).and_then(Value::as_bool))
1006        })
1007        .unwrap_or(false);
1008    let dhcpv6_enabled = ipv6
1009        .and_then(|v| {
1010            v.get("clientAddressAssignment")
1011                .and_then(|ca| ca.get("dhcpv6Enabled"))
1012                .and_then(Value::as_bool)
1013                .or_else(|| {
1014                    v.get("dhcpv6")
1015                        .and_then(|d| d.get("enabled"))
1016                        .and_then(Value::as_bool)
1017                })
1018        })
1019        .unwrap_or(false);
1020    let ipv6_prefix = ipv6.and_then(|v| {
1021        // New: additionalHostIpSubnets[0]
1022        v.get("additionalHostIpSubnets")
1023                .and_then(Value::as_array)
1024                .and_then(|a| a.first())
1025                .and_then(Value::as_str)
1026                .map(String::from)
1027                // Fallback: prefix
1028                .or_else(|| v.get("prefix").and_then(Value::as_str).map(String::from))
1029    });
1030
1031    // ── Management type inference ───────────────────────────────
1032    let has_ipv4_config = ipv4.is_some();
1033    let has_device_id = extra.contains_key("deviceId");
1034    let management = if has_ipv4_config && !has_device_id {
1035        Some(NetworkManagement::Gateway)
1036    } else if has_device_id {
1037        Some(NetworkManagement::Switch)
1038    } else if has_ipv4_config {
1039        Some(NetworkManagement::Gateway)
1040    } else {
1041        None
1042    };
1043
1044    Network {
1045        id: EntityId::Uuid(id),
1046        name,
1047        enabled,
1048        management,
1049        purpose: None,
1050        is_default,
1051        #[allow(
1052            clippy::as_conversions,
1053            clippy::cast_possible_truncation,
1054            clippy::cast_sign_loss
1055        )]
1056        vlan_id: Some(vlan_id as u16),
1057        subnet,
1058        gateway_ip,
1059        dhcp,
1060        ipv6_enabled,
1061        ipv6_mode,
1062        ipv6_prefix,
1063        dhcpv6_enabled,
1064        slaac_enabled,
1065        ntp_server,
1066        pxe_enabled,
1067        tftp_server,
1068        firewall_zone_id,
1069        isolation_enabled,
1070        internet_access_enabled,
1071        mdns_forwarding_enabled,
1072        cellular_backup_enabled,
1073        origin: map_origin(management_str),
1074        source: DataSource::IntegrationApi,
1075    }
1076}
1077
1078impl From<integration_types::NetworkResponse> for Network {
1079    fn from(n: integration_types::NetworkResponse) -> Self {
1080        parse_network_fields(
1081            n.id,
1082            n.name,
1083            n.enabled,
1084            &n.management,
1085            n.vlan_id,
1086            n.default,
1087            &n.metadata,
1088            &n.extra,
1089        )
1090    }
1091}
1092
1093impl From<integration_types::NetworkDetailsResponse> for Network {
1094    fn from(n: integration_types::NetworkDetailsResponse) -> Self {
1095        parse_network_fields(
1096            n.id,
1097            n.name,
1098            n.enabled,
1099            &n.management,
1100            n.vlan_id,
1101            n.default,
1102            &n.metadata,
1103            &n.extra,
1104        )
1105    }
1106}
1107
1108// ── WiFi Broadcast ───────────────────────────────────────────────
1109
1110impl From<integration_types::WifiBroadcastResponse> for WifiBroadcast {
1111    fn from(w: integration_types::WifiBroadcastResponse) -> Self {
1112        let broadcast_type = match w.broadcast_type.as_str() {
1113            "IOT_OPTIMIZED" => WifiBroadcastType::IotOptimized,
1114            _ => WifiBroadcastType::Standard,
1115        };
1116
1117        let security = w
1118            .security_configuration
1119            .get("type")
1120            .or_else(|| w.security_configuration.get("mode"))
1121            .and_then(|v| v.as_str())
1122            .map_or(WifiSecurityMode::Open, |mode| match mode {
1123                "WPA2_PERSONAL" => WifiSecurityMode::Wpa2Personal,
1124                "WPA3_PERSONAL" => WifiSecurityMode::Wpa3Personal,
1125                "WPA2_WPA3_PERSONAL" => WifiSecurityMode::Wpa2Wpa3Personal,
1126                "WPA2_ENTERPRISE" => WifiSecurityMode::Wpa2Enterprise,
1127                "WPA3_ENTERPRISE" => WifiSecurityMode::Wpa3Enterprise,
1128                "WPA2_WPA3_ENTERPRISE" => WifiSecurityMode::Wpa2Wpa3Enterprise,
1129                _ => WifiSecurityMode::Open,
1130            });
1131
1132        WifiBroadcast {
1133            id: EntityId::Uuid(w.id),
1134            name: w.name,
1135            enabled: w.enabled,
1136            broadcast_type,
1137            security,
1138            network_id: w
1139                .network
1140                .as_ref()
1141                .and_then(|v| v.get("networkId").or_else(|| v.get("id")))
1142                .and_then(|v| v.as_str())
1143                .and_then(|s| uuid::Uuid::parse_str(s).ok())
1144                .map(EntityId::Uuid),
1145            frequencies_ghz: extra_frequencies(&w.extra, "broadcastingFrequenciesGHz"),
1146            hidden: extra_bool(&w.extra, "hideName"),
1147            client_isolation: extra_bool(&w.extra, "clientIsolationEnabled"),
1148            band_steering: extra_bool(&w.extra, "bandSteeringEnabled"),
1149            mlo_enabled: extra_bool(&w.extra, "mloEnabled"),
1150            fast_roaming: extra_bool(&w.extra, "bssTransitionEnabled"),
1151            hotspot_enabled: w.extra.contains_key("hotspotConfiguration"),
1152            origin: origin_from_metadata(&w.metadata),
1153            source: DataSource::IntegrationApi,
1154        }
1155    }
1156}
1157
1158impl From<integration_types::WifiBroadcastDetailsResponse> for WifiBroadcast {
1159    fn from(w: integration_types::WifiBroadcastDetailsResponse) -> Self {
1160        // Re-use the overview conversion — both types have identical fields.
1161        let overview = integration_types::WifiBroadcastResponse {
1162            id: w.id,
1163            name: w.name,
1164            broadcast_type: w.broadcast_type,
1165            enabled: w.enabled,
1166            security_configuration: w.security_configuration,
1167            metadata: w.metadata,
1168            network: w.network,
1169            broadcasting_device_filter: w.broadcasting_device_filter,
1170            extra: w.extra,
1171        };
1172        Self::from(overview)
1173    }
1174}
1175
1176// ── Firewall Policy ──────────────────────────────────────────────
1177
1178impl From<integration_types::FirewallPolicyResponse> for FirewallPolicy {
1179    fn from(p: integration_types::FirewallPolicyResponse) -> Self {
1180        let action = p.action.get("type").and_then(|v| v.as_str()).map_or(
1181            FirewallAction::Block,
1182            |a| match a {
1183                "ALLOW" => FirewallAction::Allow,
1184                "REJECT" => FirewallAction::Reject,
1185                _ => FirewallAction::Block,
1186            },
1187        );
1188
1189        #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1190        let index = p
1191            .extra
1192            .get("index")
1193            .and_then(serde_json::Value::as_i64)
1194            .map(|i| i as i32);
1195
1196        // Convert structured source/destination, falling back to flat zone IDs
1197        let source_endpoint =
1198            convert_policy_endpoint(p.source.as_ref(), p.extra.get("sourceFirewallZoneId"));
1199        let destination_endpoint = convert_dest_policy_endpoint(
1200            p.destination.as_ref(),
1201            p.extra.get("destinationFirewallZoneId"),
1202        );
1203
1204        let source_summary = source_endpoint.filter.as_ref().map(TrafficFilter::summary);
1205        let destination_summary = destination_endpoint
1206            .filter
1207            .as_ref()
1208            .map(TrafficFilter::summary);
1209
1210        // Extract IP version from ipProtocolScope
1211        let ip_version = p
1212            .ip_protocol_scope
1213            .as_ref()
1214            .and_then(|v| v.get("ipVersion"))
1215            .and_then(|v| v.as_str())
1216            .map_or(crate::model::firewall::IpVersion::Both, |s| match s {
1217                "IPV4_ONLY" | "IPV4" => crate::model::firewall::IpVersion::Ipv4,
1218                "IPV6_ONLY" | "IPV6" => crate::model::firewall::IpVersion::Ipv6,
1219                _ => crate::model::firewall::IpVersion::Both,
1220            });
1221
1222        let ipsec_mode = p
1223            .extra
1224            .get("ipsecFilter")
1225            .and_then(|v| v.as_str())
1226            .map(String::from);
1227
1228        let connection_states = p
1229            .extra
1230            .get("connectionStateFilter")
1231            .and_then(|v| v.as_array())
1232            .map(|arr| {
1233                arr.iter()
1234                    .filter_map(|v| v.as_str().map(String::from))
1235                    .collect()
1236            })
1237            .unwrap_or_default();
1238
1239        FirewallPolicy {
1240            id: EntityId::Uuid(p.id),
1241            name: p.name,
1242            description: p.description,
1243            enabled: p.enabled,
1244            index,
1245            action,
1246            ip_version,
1247            source: source_endpoint,
1248            destination: destination_endpoint,
1249            source_summary,
1250            destination_summary,
1251            protocol_summary: None,
1252            schedule: None,
1253            ipsec_mode,
1254            connection_states,
1255            logging_enabled: p.logging_enabled,
1256            origin: p.metadata.as_ref().and_then(origin_from_metadata),
1257            data_source: DataSource::IntegrationApi,
1258        }
1259    }
1260}
1261
1262/// Convert API source/destination to a domain `PolicyEndpoint`.
1263/// Falls back to flat zone ID fields from the `extra` map.
1264fn convert_policy_endpoint(
1265    endpoint: Option<&integration_types::FirewallPolicySource>,
1266    flat_zone_id: Option<&serde_json::Value>,
1267) -> PolicyEndpoint {
1268    if let Some(ep) = endpoint {
1269        PolicyEndpoint {
1270            zone_id: ep.zone_id.map(EntityId::Uuid),
1271            filter: ep
1272                .traffic_filter
1273                .as_ref()
1274                .map(convert_source_traffic_filter),
1275        }
1276    } else {
1277        // Fallback: extract zone ID from flat field
1278        let zone_id = flat_zone_id
1279            .and_then(|v| v.as_str())
1280            .and_then(|s| uuid::Uuid::parse_str(s).ok())
1281            .map(EntityId::Uuid);
1282        PolicyEndpoint {
1283            zone_id,
1284            filter: None,
1285        }
1286    }
1287}
1288
1289// Overload for destination (different filter enum)
1290fn convert_dest_policy_endpoint(
1291    endpoint: Option<&integration_types::FirewallPolicyDestination>,
1292    flat_zone_id: Option<&serde_json::Value>,
1293) -> PolicyEndpoint {
1294    if let Some(ep) = endpoint {
1295        PolicyEndpoint {
1296            zone_id: ep.zone_id.map(EntityId::Uuid),
1297            filter: ep.traffic_filter.as_ref().map(convert_dest_traffic_filter),
1298        }
1299    } else {
1300        let zone_id = flat_zone_id
1301            .and_then(|v| v.as_str())
1302            .and_then(|s| uuid::Uuid::parse_str(s).ok())
1303            .map(EntityId::Uuid);
1304        PolicyEndpoint {
1305            zone_id,
1306            filter: None,
1307        }
1308    }
1309}
1310
1311fn convert_source_traffic_filter(f: &integration_types::SourceTrafficFilter) -> TrafficFilter {
1312    use integration_types::SourceTrafficFilter as S;
1313    match f {
1314        S::Network {
1315            network_filter,
1316            mac_address_filter,
1317            port_filter,
1318        } => TrafficFilter::Network {
1319            network_ids: network_filter
1320                .network_ids
1321                .iter()
1322                .copied()
1323                .map(EntityId::Uuid)
1324                .collect(),
1325            match_opposite: network_filter.match_opposite,
1326            mac_addresses: mac_address_filter
1327                .as_ref()
1328                .map(|m| m.mac_addresses.clone())
1329                .unwrap_or_default(),
1330            ports: port_filter.as_ref().map(convert_port_filter),
1331        },
1332        S::IpAddress {
1333            ip_address_filter,
1334            mac_address_filter,
1335            port_filter,
1336        } => TrafficFilter::IpAddress {
1337            addresses: convert_ip_address_filter(ip_address_filter),
1338            match_opposite: ip_filter_match_opposite(ip_address_filter),
1339            mac_addresses: mac_address_filter
1340                .as_ref()
1341                .map(|m| m.mac_addresses.clone())
1342                .unwrap_or_default(),
1343            ports: port_filter.as_ref().map(convert_port_filter),
1344        },
1345        S::MacAddress {
1346            mac_address_filter,
1347            port_filter,
1348        } => TrafficFilter::MacAddress {
1349            mac_addresses: mac_address_filter.mac_addresses.clone(),
1350            ports: port_filter.as_ref().map(convert_port_filter),
1351        },
1352        S::Port { port_filter } => TrafficFilter::Port {
1353            ports: convert_port_filter(port_filter),
1354        },
1355        S::Region {
1356            region_filter,
1357            port_filter,
1358        } => TrafficFilter::Region {
1359            regions: region_filter.regions.clone(),
1360            ports: port_filter.as_ref().map(convert_port_filter),
1361        },
1362        S::Unknown => TrafficFilter::Other {
1363            raw_type: "UNKNOWN".into(),
1364        },
1365    }
1366}
1367
1368fn convert_dest_traffic_filter(f: &integration_types::DestTrafficFilter) -> TrafficFilter {
1369    use integration_types::DestTrafficFilter as D;
1370    match f {
1371        D::Network {
1372            network_filter,
1373            port_filter,
1374        } => TrafficFilter::Network {
1375            network_ids: network_filter
1376                .network_ids
1377                .iter()
1378                .copied()
1379                .map(EntityId::Uuid)
1380                .collect(),
1381            match_opposite: network_filter.match_opposite,
1382            mac_addresses: Vec::new(),
1383            ports: port_filter.as_ref().map(convert_port_filter),
1384        },
1385        D::IpAddress {
1386            ip_address_filter,
1387            port_filter,
1388        } => TrafficFilter::IpAddress {
1389            addresses: convert_ip_address_filter(ip_address_filter),
1390            match_opposite: ip_filter_match_opposite(ip_address_filter),
1391            mac_addresses: Vec::new(),
1392            ports: port_filter.as_ref().map(convert_port_filter),
1393        },
1394        D::Port { port_filter } => TrafficFilter::Port {
1395            ports: convert_port_filter(port_filter),
1396        },
1397        D::Region {
1398            region_filter,
1399            port_filter,
1400        } => TrafficFilter::Region {
1401            regions: region_filter.regions.clone(),
1402            ports: port_filter.as_ref().map(convert_port_filter),
1403        },
1404        D::Application {
1405            application_filter,
1406            port_filter,
1407        } => TrafficFilter::Application {
1408            application_ids: application_filter.application_ids.clone(),
1409            ports: port_filter.as_ref().map(convert_port_filter),
1410        },
1411        D::ApplicationCategory {
1412            application_category_filter,
1413            port_filter,
1414        } => TrafficFilter::ApplicationCategory {
1415            category_ids: application_category_filter.application_category_ids.clone(),
1416            ports: port_filter.as_ref().map(convert_port_filter),
1417        },
1418        D::Domain {
1419            domain_filter,
1420            port_filter,
1421        } => {
1422            let domains = match domain_filter {
1423                integration_types::DomainFilter::Specific { domains } => domains.clone(),
1424                integration_types::DomainFilter::Unknown => Vec::new(),
1425            };
1426            TrafficFilter::Domain {
1427                domains,
1428                ports: port_filter.as_ref().map(convert_port_filter),
1429            }
1430        }
1431        D::Unknown => TrafficFilter::Other {
1432            raw_type: "UNKNOWN".into(),
1433        },
1434    }
1435}
1436
1437fn convert_port_filter(pf: &integration_types::PortFilter) -> PortSpec {
1438    match pf {
1439        integration_types::PortFilter::Ports {
1440            items,
1441            match_opposite,
1442        } => PortSpec::Values {
1443            items: items
1444                .iter()
1445                .map(|item| match item {
1446                    integration_types::PortItem::Number { value } => value.clone(),
1447                    integration_types::PortItem::Range {
1448                        start_port,
1449                        end_port,
1450                    } => format!("{start_port}-{end_port}"),
1451                    integration_types::PortItem::Unknown => "?".into(),
1452                })
1453                .collect(),
1454            match_opposite: *match_opposite,
1455        },
1456        integration_types::PortFilter::TrafficMatchingList {
1457            traffic_matching_list_id,
1458            match_opposite,
1459        } => PortSpec::MatchingList {
1460            list_id: EntityId::Uuid(*traffic_matching_list_id),
1461            match_opposite: *match_opposite,
1462        },
1463        integration_types::PortFilter::Unknown => PortSpec::Values {
1464            items: Vec::new(),
1465            match_opposite: false,
1466        },
1467    }
1468}
1469
1470fn convert_ip_address_filter(f: &integration_types::IpAddressFilter) -> Vec<IpSpec> {
1471    match f {
1472        integration_types::IpAddressFilter::Specific { items, .. } => items
1473            .iter()
1474            .map(|item| match item {
1475                integration_types::IpAddressItem::Address { value } => IpSpec::Address {
1476                    value: value.clone(),
1477                },
1478                integration_types::IpAddressItem::Range { start, stop } => IpSpec::Range {
1479                    start: start.clone(),
1480                    stop: stop.clone(),
1481                },
1482                integration_types::IpAddressItem::Subnet { value } => IpSpec::Subnet {
1483                    value: value.clone(),
1484                },
1485            })
1486            .collect(),
1487        integration_types::IpAddressFilter::TrafficMatchingList {
1488            traffic_matching_list_id,
1489            ..
1490        } => vec![IpSpec::MatchingList {
1491            list_id: EntityId::Uuid(*traffic_matching_list_id),
1492        }],
1493        integration_types::IpAddressFilter::Unknown => Vec::new(),
1494    }
1495}
1496
1497fn ip_filter_match_opposite(f: &integration_types::IpAddressFilter) -> bool {
1498    match f {
1499        integration_types::IpAddressFilter::Specific { match_opposite, .. }
1500        | integration_types::IpAddressFilter::TrafficMatchingList { match_opposite, .. } => {
1501            *match_opposite
1502        }
1503        integration_types::IpAddressFilter::Unknown => false,
1504    }
1505}
1506
1507// ── Firewall Zone ────────────────────────────────────────────────
1508
1509impl From<integration_types::FirewallZoneResponse> for FirewallZone {
1510    fn from(z: integration_types::FirewallZoneResponse) -> Self {
1511        FirewallZone {
1512            id: EntityId::Uuid(z.id),
1513            name: z.name,
1514            network_ids: z.network_ids.into_iter().map(EntityId::Uuid).collect(),
1515            origin: origin_from_metadata(&z.metadata),
1516            source: DataSource::IntegrationApi,
1517        }
1518    }
1519}
1520
1521// ── ACL Rule ─────────────────────────────────────────────────────
1522
1523impl From<integration_types::AclRuleResponse> for AclRule {
1524    fn from(r: integration_types::AclRuleResponse) -> Self {
1525        let rule_type = match r.rule_type.as_str() {
1526            "MAC" => AclRuleType::Mac,
1527            _ => AclRuleType::Ipv4,
1528        };
1529
1530        let action = match r.action.as_str() {
1531            "ALLOW" => AclAction::Allow,
1532            _ => AclAction::Block,
1533        };
1534
1535        AclRule {
1536            id: EntityId::Uuid(r.id),
1537            name: r.name,
1538            enabled: r.enabled,
1539            rule_type,
1540            action,
1541            source_summary: None,
1542            destination_summary: None,
1543            origin: origin_from_metadata(&r.metadata),
1544            source: DataSource::IntegrationApi,
1545        }
1546    }
1547}
1548
1549// ── NAT Policy ──────────────────────────────────────────────────
1550
1551impl From<integration_types::NatPolicyResponse> for NatPolicy {
1552    fn from(r: integration_types::NatPolicyResponse) -> Self {
1553        let nat_type = match r.nat_type.as_str() {
1554            "MASQUERADE" => NatType::Masquerade,
1555            "SOURCE_NAT" => NatType::Source,
1556            _ => NatType::Destination,
1557        };
1558
1559        let src_address = r
1560            .source
1561            .as_ref()
1562            .and_then(|s| s.get("address"))
1563            .and_then(serde_json::Value::as_str)
1564            .map(ToOwned::to_owned);
1565        let src_port = r
1566            .source
1567            .as_ref()
1568            .and_then(|s| s.get("port"))
1569            .and_then(serde_json::Value::as_str)
1570            .map(ToOwned::to_owned);
1571        let dst_address = r
1572            .destination
1573            .as_ref()
1574            .and_then(|d| d.get("address"))
1575            .and_then(serde_json::Value::as_str)
1576            .map(ToOwned::to_owned);
1577        let dst_port = r
1578            .destination
1579            .as_ref()
1580            .and_then(|d| d.get("port"))
1581            .and_then(serde_json::Value::as_str)
1582            .map(ToOwned::to_owned);
1583
1584        NatPolicy {
1585            id: EntityId::Uuid(r.id),
1586            name: r.name,
1587            description: r.description,
1588            enabled: r.enabled,
1589            nat_type,
1590            interface_id: r.interface_id.map(EntityId::Uuid),
1591            protocol: r.protocol,
1592            src_address,
1593            src_port,
1594            dst_address,
1595            dst_port,
1596            translated_address: r.translated_address,
1597            translated_port: r.translated_port,
1598            origin: r.metadata.as_ref().and_then(origin_from_metadata),
1599            data_source: DataSource::IntegrationApi,
1600        }
1601    }
1602}
1603
1604/// Convert a v2 API NAT rule JSON object into a domain `NatPolicy`.
1605pub fn nat_policy_from_v2(v: &serde_json::Value) -> Option<NatPolicy> {
1606    let id_str = v.get("_id").and_then(|v| v.as_str())?;
1607    let nat_type_str = v.get("type").and_then(|v| v.as_str()).unwrap_or("DNAT");
1608    let nat_type = match nat_type_str {
1609        "MASQUERADE" => NatType::Masquerade,
1610        "SNAT" => NatType::Source,
1611        _ => NatType::Destination,
1612    };
1613
1614    let filter_addr = |filter: Option<&serde_json::Value>| -> Option<String> {
1615        filter
1616            .and_then(|f| f.get("address"))
1617            .and_then(|v| v.as_str())
1618            .map(ToOwned::to_owned)
1619    };
1620    let filter_port = |filter: Option<&serde_json::Value>| -> Option<String> {
1621        filter
1622            .and_then(|f| f.get("port"))
1623            .and_then(|v| v.as_str())
1624            .map(ToOwned::to_owned)
1625    };
1626
1627    let src_filter = v.get("source_filter");
1628    let dst_filter = v.get("destination_filter");
1629
1630    Some(NatPolicy {
1631        id: EntityId::from(id_str.to_owned()),
1632        name: v
1633            .get("description")
1634            .and_then(|v| v.as_str())
1635            .unwrap_or("")
1636            .to_owned(),
1637        description: None,
1638        enabled: v
1639            .get("enabled")
1640            .and_then(serde_json::Value::as_bool)
1641            .unwrap_or(false),
1642        nat_type,
1643        interface_id: v
1644            .get("in_interface")
1645            .or_else(|| v.get("out_interface"))
1646            .and_then(|v| v.as_str())
1647            .map(|s| EntityId::from(s.to_owned())),
1648        protocol: v
1649            .get("protocol")
1650            .and_then(|v| v.as_str())
1651            .map(ToOwned::to_owned),
1652        src_address: filter_addr(src_filter),
1653        src_port: filter_port(src_filter),
1654        dst_address: filter_addr(dst_filter),
1655        dst_port: filter_port(dst_filter),
1656        translated_address: v
1657            .get("ip_address")
1658            .and_then(|v| v.as_str())
1659            .map(ToOwned::to_owned),
1660        translated_port: v
1661            .get("port")
1662            .and_then(|v| v.as_str())
1663            .map(ToOwned::to_owned),
1664        origin: None,
1665        data_source: DataSource::SessionApi,
1666    })
1667}
1668
1669// ── DNS Policy ───────────────────────────────────────────────────
1670
1671impl From<integration_types::DnsPolicyResponse> for DnsPolicy {
1672    fn from(d: integration_types::DnsPolicyResponse) -> Self {
1673        let policy_type = match d.policy_type.as_str() {
1674            "A" => DnsPolicyType::ARecord,
1675            "AAAA" => DnsPolicyType::AaaaRecord,
1676            "CNAME" => DnsPolicyType::CnameRecord,
1677            "MX" => DnsPolicyType::MxRecord,
1678            "TXT" => DnsPolicyType::TxtRecord,
1679            "SRV" => DnsPolicyType::SrvRecord,
1680            _ => DnsPolicyType::ForwardDomain,
1681        };
1682
1683        DnsPolicy {
1684            id: EntityId::Uuid(d.id),
1685            policy_type,
1686            domain: d.domain.unwrap_or_default(),
1687            value: dns_value_from_extra(policy_type, &d.extra),
1688            #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1689            ttl_seconds: d
1690                .extra
1691                .get("ttlSeconds")
1692                .and_then(serde_json::Value::as_u64)
1693                .map(|t| t as u32),
1694            origin: origin_from_metadata(&d.metadata),
1695            source: DataSource::IntegrationApi,
1696        }
1697    }
1698}
1699
1700// ── Traffic Matching List ────────────────────────────────────────
1701
1702impl From<integration_types::TrafficMatchingListResponse> for TrafficMatchingList {
1703    fn from(t: integration_types::TrafficMatchingListResponse) -> Self {
1704        let items = t
1705            .extra
1706            .get("items")
1707            .and_then(|v| v.as_array())
1708            .map(|arr| {
1709                arr.iter()
1710                    .filter_map(traffic_matching_item_to_string)
1711                    .collect()
1712            })
1713            .unwrap_or_default();
1714
1715        TrafficMatchingList {
1716            id: EntityId::Uuid(t.id),
1717            name: t.name,
1718            list_type: t.list_type,
1719            items,
1720            origin: None,
1721        }
1722    }
1723}
1724
1725// ── Voucher ──────────────────────────────────────────────────────
1726
1727impl From<integration_types::VoucherResponse> for Voucher {
1728    fn from(v: integration_types::VoucherResponse) -> Self {
1729        #[allow(
1730            clippy::as_conversions,
1731            clippy::cast_possible_truncation,
1732            clippy::cast_sign_loss
1733        )]
1734        Voucher {
1735            id: EntityId::Uuid(v.id),
1736            code: v.code,
1737            name: Some(v.name),
1738            created_at: parse_iso(&v.created_at),
1739            activated_at: v.activated_at.as_deref().and_then(parse_iso),
1740            expires_at: v.expires_at.as_deref().and_then(parse_iso),
1741            expired: v.expired,
1742            time_limit_minutes: Some(v.time_limit_minutes as u32),
1743            data_usage_limit_mb: v.data_usage_limit_m_bytes.map(|b| b as u64),
1744            authorized_guest_limit: v.authorized_guest_limit.map(|l| l as u32),
1745            authorized_guest_count: Some(v.authorized_guest_count as u32),
1746            rx_rate_limit_kbps: v.rx_rate_limit_kbps.map(|r| r as u64),
1747            tx_rate_limit_kbps: v.tx_rate_limit_kbps.map(|r| r as u64),
1748            source: DataSource::IntegrationApi,
1749        }
1750    }
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755    use super::*;
1756    use serde_json::json;
1757
1758    #[test]
1759    fn device_type_from_legacy_type_field() {
1760        assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
1761        assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
1762        assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
1763        assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
1764    }
1765
1766    #[test]
1767    fn device_type_from_model_fallback() {
1768        assert_eq!(
1769            infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
1770            DeviceType::AccessPoint
1771        );
1772        assert_eq!(
1773            infer_device_type("unknown", Some(&"U6-LR".into())),
1774            DeviceType::AccessPoint
1775        );
1776        assert_eq!(
1777            infer_device_type("unknown", Some(&"USW-24-PoE".into())),
1778            DeviceType::Switch
1779        );
1780        assert_eq!(
1781            infer_device_type("unknown", Some(&"UDM-Pro".into())),
1782            DeviceType::Gateway
1783        );
1784        assert_eq!(
1785            infer_device_type("unknown", Some(&"UCG-Max".into())),
1786            DeviceType::Gateway
1787        );
1788    }
1789
1790    #[test]
1791    fn integration_device_type_gateway_by_model() {
1792        // UCG Max has "switching" but not "routing" — should still be Gateway
1793        assert_eq!(
1794            infer_device_type_integration(&["switching".into()], "UCG-Max"),
1795            DeviceType::Gateway
1796        );
1797        // UDM with both features
1798        assert_eq!(
1799            infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
1800            DeviceType::Gateway
1801        );
1802    }
1803
1804    #[test]
1805    fn device_state_mapping() {
1806        assert_eq!(map_device_state(0), DeviceState::Offline);
1807        assert_eq!(map_device_state(1), DeviceState::Online);
1808        assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
1809        assert_eq!(map_device_state(4), DeviceState::Updating);
1810        assert_eq!(map_device_state(5), DeviceState::GettingReady);
1811        assert_eq!(map_device_state(99), DeviceState::Unknown);
1812    }
1813
1814    #[test]
1815    fn legacy_site_uses_desc_as_display_name() {
1816        let site = SessionSite {
1817            id: "abc123".into(),
1818            name: "default".into(),
1819            desc: Some("Main Office".into()),
1820            role: None,
1821            extra: serde_json::Map::new(),
1822        };
1823        let converted: Site = site.into();
1824        assert_eq!(converted.internal_name, "default");
1825        assert_eq!(converted.name, "Main Office");
1826    }
1827
1828    #[test]
1829    fn legacy_site_falls_back_to_name_when_desc_empty() {
1830        let site = SessionSite {
1831            id: "abc123".into(),
1832            name: "branch-1".into(),
1833            desc: Some(String::new()),
1834            role: None,
1835            extra: serde_json::Map::new(),
1836        };
1837        let converted: Site = site.into();
1838        assert_eq!(converted.name, "branch-1");
1839    }
1840
1841    #[test]
1842    fn event_category_mapping() {
1843        assert_eq!(
1844            map_event_category(Some(&"wlan".into())),
1845            EventCategory::Network
1846        );
1847        assert_eq!(
1848            map_event_category(Some(&"device".into())),
1849            EventCategory::Device
1850        );
1851        assert_eq!(
1852            map_event_category(Some(&"admin".into())),
1853            EventCategory::Admin
1854        );
1855        assert_eq!(map_event_category(None), EventCategory::Unknown);
1856    }
1857
1858    #[test]
1859    fn channel_frequency_bands() {
1860        assert_eq!(channel_to_frequency(Some(6)), Some(2.4));
1861        assert_eq!(channel_to_frequency(Some(36)), Some(5.0));
1862        assert_eq!(channel_to_frequency(Some(149)), Some(5.0));
1863        assert_eq!(channel_to_frequency(None), None);
1864    }
1865
1866    #[test]
1867    fn integration_wifi_broadcast_preserves_standard_fields() {
1868        let response = integration_types::WifiBroadcastResponse {
1869            id: uuid::Uuid::nil(),
1870            name: "Main".into(),
1871            broadcast_type: "STANDARD".into(),
1872            enabled: true,
1873            security_configuration: json!({"mode": "WPA2_PERSONAL"}),
1874            metadata: json!({"origin": "USER"}),
1875            network: Some(json!({"id": uuid::Uuid::nil().to_string()})),
1876            broadcasting_device_filter: None,
1877            extra: HashMap::from([
1878                ("broadcastingFrequenciesGHz".into(), json!([2.4, 5.0])),
1879                ("hideName".into(), json!(true)),
1880                ("clientIsolationEnabled".into(), json!(true)),
1881                ("bandSteeringEnabled".into(), json!(true)),
1882                ("mloEnabled".into(), json!(false)),
1883                ("bssTransitionEnabled".into(), json!(true)),
1884                (
1885                    "hotspotConfiguration".into(),
1886                    json!({"type": "CAPTIVE_PORTAL"}),
1887                ),
1888            ]),
1889        };
1890
1891        let wifi = WifiBroadcast::from(response);
1892        assert_eq!(wifi.frequencies_ghz.len(), 2);
1893        assert!((wifi.frequencies_ghz[0] - 2.4).abs() < f32::EPSILON);
1894        assert!((wifi.frequencies_ghz[1] - 5.0).abs() < f32::EPSILON);
1895        assert!(wifi.hidden);
1896        assert!(wifi.client_isolation);
1897        assert!(wifi.band_steering);
1898        assert!(wifi.fast_roaming);
1899        assert!(wifi.hotspot_enabled);
1900    }
1901
1902    #[test]
1903    fn integration_dns_policy_uses_type_specific_fields() {
1904        let response = integration_types::DnsPolicyResponse {
1905            id: uuid::Uuid::nil(),
1906            policy_type: "A".into(),
1907            enabled: true,
1908            domain: Some("example.com".into()),
1909            metadata: json!({"origin": "USER"}),
1910            extra: HashMap::from([
1911                ("ipv4Address".into(), json!("192.168.1.10")),
1912                ("ttlSeconds".into(), json!(600)),
1913            ]),
1914        };
1915
1916        let dns = DnsPolicy::from(response);
1917        assert_eq!(dns.value, "192.168.1.10");
1918        assert_eq!(dns.ttl_seconds, Some(600));
1919    }
1920
1921    #[test]
1922    fn integration_traffic_matching_list_formats_structured_items() {
1923        let response = integration_types::TrafficMatchingListResponse {
1924            id: uuid::Uuid::nil(),
1925            name: "Ports".into(),
1926            list_type: "PORT".into(),
1927            extra: HashMap::from([(
1928                "items".into(),
1929                json!([
1930                    {"type": "PORT_NUMBER", "value": 443},
1931                    {"type": "PORT_RANGE", "start": 1000, "stop": 2000}
1932                ]),
1933            )]),
1934        };
1935
1936        let list = TrafficMatchingList::from(response);
1937        assert_eq!(list.items, vec!["443".to_owned(), "1000-2000".to_owned()]);
1938    }
1939}