xybrid-core 0.1.0

Core runtime for hybrid cloud-edge AI inference: model execution, pipeline orchestration, and routing primitives.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
//! Platform-bridged signals consumed by the resource monitor.
//!
//! `ResourceMonitor` covers what `sysinfo` can answer cross-platform: CPU,
//! memory, RSS. Battery level and thermal state require platform APIs that
//! sysinfo doesn't expose — `UIDevice.batteryLevel` on iOS,
//! `BatteryManager.ACTION_BATTERY_CHANGED` on Android, `NSProcessInfo`
//! thermalState on macOS, `GetSystemPowerStatus` on Windows, sysfs paths on
//! Linux.
//!
//! This module is the seam. Hosts push values in via [`set_battery_level`] /
//! [`set_thermal_state`]; [`ResourceMonitor::current_snapshot`] reads them
//! out. The Linux desktop case is handled in-process by
//! [`refresh_native_platform_state`] — other platforms have no in-Rust
//! native source today and rely on the host to push.
//!
//! ### Why push-state and not callback interfaces
//!
//! UniFFI callback interfaces and flutter_rust_bridge `DartFn`s both work,
//! but every mobile platform API for these signals already emits change
//! notifications (`UIDevice.batteryLevelDidChangeNotification`,
//! `BatteryManager.ACTION_BATTERY_CHANGED`, `PowerManager.OnThermalStatusChangedListener`).
//! Push-state matches that grain — hosts forward each notification with a
//! single FFI call and forget — instead of forcing every host to poll on a
//! timer and re-marshal across the FFI boundary.

use std::sync::RwLock;

use super::types::ThermalState;

/// Platform-bridged signals.
///
/// Both fields are `Option`: `None` means "no host or native source has
/// reported a value yet." Routing code is expected to treat `None` as
/// "unknown" rather than substituting an optimistic default.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct PlatformState {
    pub battery_pct: Option<u8>,
    pub thermal_state: Option<ThermalState>,
}

impl PlatformState {
    /// Const empty state — used to initialize the global without a runtime
    /// `Default::default()` call.
    pub const EMPTY: Self = Self {
        battery_pct: None,
        thermal_state: None,
    };
}

static GLOBAL: RwLock<PlatformState> = RwLock::new(PlatformState::EMPTY);

/// Read the current platform-bridged state.
///
/// Lock poisoning falls back to [`PlatformState::EMPTY`] rather than
/// panicking — a poisoned lock means a previous writer panicked, which
/// shouldn't take down inference.
pub fn current_platform_state() -> PlatformState {
    GLOBAL.read().map(|g| *g).unwrap_or(PlatformState::EMPTY)
}

/// Replace the entire platform state in one write. Use the per-field
/// setters for incremental updates; this is for tests and for hosts that
/// have a complete state snapshot in hand.
pub fn set_platform_state(state: PlatformState) {
    if let Ok(mut g) = GLOBAL.write() {
        *g = state;
    }
}

/// Set battery level. Values above 100 are clamped.
pub fn set_battery_level(pct: u8) {
    if let Ok(mut g) = GLOBAL.write() {
        g.battery_pct = Some(pct.min(100));
    }
}

/// Mark battery level as unknown.
pub fn clear_battery_level() {
    if let Ok(mut g) = GLOBAL.write() {
        g.battery_pct = None;
    }
}

/// Set thermal state.
pub fn set_thermal_state(state: ThermalState) {
    if let Ok(mut g) = GLOBAL.write() {
        g.thermal_state = Some(state);
    }
}

/// Mark thermal state as unknown.
pub fn clear_thermal_state() {
    if let Ok(mut g) = GLOBAL.write() {
        g.thermal_state = None;
    }
}

/// Refresh from in-process native sources.
///
/// - **Linux**: reads `/sys/class/power_supply/BAT[01]/capacity` and
///   `/sys/class/thermal/thermal_zone*/temp`.
/// - **macOS**: reads `NSProcessInfo.thermalState` (no entitlement
///   required, fast Foundation call) and queries IOKit
///   `IOPSCopyPowerSourcesInfo` for battery charge. Both calls are
///   in-process and thread-safe per Apple's docs — no fork/exec, no
///   COM, safe on the runtime thread that
///   `Orchestrator::execute_stage_async` lands on.
/// - **iOS**: reads `NSProcessInfo.thermalState` (same Foundation API
///   as macOS, no entitlement). Battery comes from the host via the
///   UniFFI surface — `UIDevice.batteryLevel` requires UIKit which
///   doesn't belong in `xybrid-core`, so the Swift wrapper subscribes
///   to `UIDevice.batteryLevelDidChangeNotification` and pushes.
/// - **Windows**: reads `GetSystemPowerStatus` (Win32, in-process)
///   for battery on the cache-miss path. Thermal is sourced from
///   `MSAcpi_ThermalZoneTemperature` over WMI (`ROOT\WMI`); COM
///   init and `IWbemServices::ExecQuery` would freeze the runtime
///   thread that `Orchestrator::execute_stage_async` lands on, so
///   the WMI loop runs on a dedicated background thread that pushes
///   results through the public setters.
/// - **Android**: no-op. Hosts push state via the public setters
///   from platform observers; the Kotlin `Xybrid.init(context)`
///   wrapper registers `BatteryManager.ACTION_BATTERY_CHANGED` and
///   `PowerManager.OnThermalStatusChangedListener`.
///
/// All in-process refreshers go through the same setters a host would
/// use, so behaviour is uniform whether data comes from sysfs, IOKit, or
/// a UniFFI host.
///
/// `ResourceMonitor::refresh_locked` calls this on every cache miss, so
/// callers normally don't need to invoke it directly.
pub fn refresh_native_platform_state() {
    #[cfg(target_os = "linux")]
    linux::refresh();
    #[cfg(any(target_os = "macos", target_os = "ios"))]
    apple::refresh();
    #[cfg(target_os = "windows")]
    windows::refresh();
}

#[cfg(target_os = "linux")]
mod linux {
    use super::{set_battery_level, set_thermal_state, ThermalState};
    use std::fs;

    pub(super) fn refresh() {
        if let Some(pct) = read_battery_pct() {
            set_battery_level(pct);
        }
        if let Some(state) = read_thermal_state() {
            set_thermal_state(state);
        }
    }

    fn read_battery_pct() -> Option<u8> {
        const PATHS: &[&str] = &[
            "/sys/class/power_supply/BAT0/capacity",
            "/sys/class/power_supply/BAT1/capacity",
        ];
        for path in PATHS {
            if let Ok(contents) = fs::read_to_string(path) {
                if let Ok(pct) = contents.trim().parse::<u8>() {
                    return Some(pct.min(100));
                }
            }
        }
        None
    }

    fn read_thermal_state() -> Option<ThermalState> {
        // thermal_zone0 is conventionally the CPU package on most distros;
        // thermal_zone1 is the fallback when zone0 is a different sensor
        // (e.g. ACPI vs. coretemp ordering varies across kernels). hwmon0
        // is a last-resort path for systems without /sys/class/thermal at
        // all (containers, some embedded boards).
        const PATHS: &[&str] = &[
            "/sys/class/thermal/thermal_zone0/temp",
            "/sys/class/thermal/thermal_zone1/temp",
            "/sys/class/hwmon/hwmon0/temp1_input",
        ];
        for path in PATHS {
            if let Ok(contents) = fs::read_to_string(path) {
                if let Ok(milli) = contents.trim().parse::<i32>() {
                    let celsius = milli as f32 / 1000.0;
                    return Some(thermal_from_celsius(celsius));
                }
            }
        }
        None
    }

    fn thermal_from_celsius(c: f32) -> ThermalState {
        // Thresholds match the documented bands on `ThermalState`'s variant
        // docs (`Normal < 60`, `Warm 60-70`, `Hot 70-80`, `Critical >= 80`).
        if c >= 80.0 {
            ThermalState::Critical
        } else if c >= 70.0 {
            ThermalState::Hot
        } else if c >= 60.0 {
            ThermalState::Warm
        } else {
            ThermalState::Normal
        }
    }

    #[cfg(test)]
    mod tests {
        use super::*;

        #[test]
        fn celsius_bands_match_thermal_state_docs() {
            assert_eq!(thermal_from_celsius(25.0), ThermalState::Normal);
            assert_eq!(thermal_from_celsius(59.9), ThermalState::Normal);
            assert_eq!(thermal_from_celsius(60.0), ThermalState::Warm);
            assert_eq!(thermal_from_celsius(69.9), ThermalState::Warm);
            assert_eq!(thermal_from_celsius(70.0), ThermalState::Hot);
            assert_eq!(thermal_from_celsius(79.9), ThermalState::Hot);
            assert_eq!(thermal_from_celsius(80.0), ThermalState::Critical);
            assert_eq!(thermal_from_celsius(95.0), ThermalState::Critical);
        }
    }
}

#[cfg(any(target_os = "macos", target_os = "ios"))]
mod apple {
    //! Apple native pollers (macOS + iOS).
    //!
    //! Thermal: `NSProcessInfo.thermalState` — direct Foundation call,
    //! no entitlement, microsecond-class. Same Foundation API on both
    //! platforms, so a single code path covers macOS and iOS.
    //!
    //! Battery: IOKit `IOPSCopyPowerSourcesInfo` →
    //! `IOPSCopyPowerSourcesList` → `IOPSGetPowerSourceDescription`,
    //! reading `kIOPSCurrentCapacityKey` / `kIOPSMaxCapacityKey` from
    //! the per-source dictionary. **macOS only** — iOS gates the IOPS
    //! APIs behind a private entitlement, and the public path
    //! (`UIDevice.batteryLevel`) requires UIKit which doesn't belong in
    //! `xybrid-core`. iOS hosts therefore push battery via the UniFFI
    //! surface; the Swift wrapper subscribes to
    //! `UIDevice.batteryLevelDidChangeNotification`.
    //!
    //! All in-process pollers are safe on the cache-miss hot path that
    //! `Orchestrator::execute_stage_async` invokes via
    //! `ResourceMonitor::current_snapshot`. macOS devices without a
    //! battery (Mac mini, Mac Studio, Mac Pro) return an empty source
    //! list and we report `None`.

    use objc2_foundation::NSProcessInfo;

    #[cfg(target_os = "macos")]
    use core::ffi::{c_void, CStr};
    #[cfg(target_os = "macos")]
    use objc2_core_foundation::{CFDictionary, CFNumber, CFString, CFType};
    #[cfg(target_os = "macos")]
    use objc2_io_kit::{
        kIOPSCurrentCapacityKey, kIOPSMaxCapacityKey, IOPSCopyPowerSourcesInfo,
        IOPSCopyPowerSourcesList, IOPSGetPowerSourceDescription,
    };

    #[cfg(target_os = "macos")]
    use super::set_battery_level;
    use super::{set_thermal_state, ThermalState};

    pub(super) fn refresh() {
        set_thermal_state(read_thermal_state());
        // iOS routes battery through the host (Swift wrapper) since the
        // public path needs UIKit. macOS uses IOKit in-process.
        #[cfg(target_os = "macos")]
        if let Some(pct) = read_battery_pct() {
            set_battery_level(pct);
        }
    }

    fn read_thermal_state() -> ThermalState {
        // `NSProcessInfo.thermalState` returns one of four discrete states
        // matching the documented API levels (Nominal, Fair, Serious,
        // Critical). The Foundation method is marked `unsafe` because
        // it's an Objective-C method invocation, but it is documented
        // thread-safe and never null on every macOS we support — there
        // is no precondition the caller can violate.
        let info = NSProcessInfo::processInfo();
        // `thermalState` is exposed as safe in objc2-foundation 0.3 —
        // the binding wraps the Objective-C call which has no caller-
        // side preconditions and is documented thread-safe.
        let raw = info.thermalState().0 as i64;
        thermal_from_nsprocessinfo(raw)
    }

    /// Map the raw `NSProcessInfoThermalState` integer to our
    /// `ThermalState`. Documented values:
    /// - 0 = Nominal   → Normal
    /// - 1 = Fair      → Warm
    /// - 2 = Serious   → Hot
    /// - 3 = Critical  → Critical
    ///
    /// Unexpected values fall back to Normal — Foundation has only ever
    /// shipped these four, but a future addition shouldn't crash the
    /// inference path.
    fn thermal_from_nsprocessinfo(raw: i64) -> ThermalState {
        match raw {
            0 => ThermalState::Normal,
            1 => ThermalState::Warm,
            2 => ThermalState::Hot,
            3 => ThermalState::Critical,
            _ => ThermalState::Normal,
        }
    }

    #[cfg(target_os = "macos")]
    fn read_battery_pct() -> Option<u8> {
        // `IOPSCopyPowerSourcesInfo` is the documented entry point; per
        // Apple's docs it does no I/O of its own, just snapshots a
        // pre-aggregated registry blob. Returns `None` on systems where
        // power-source info is unavailable (sandboxed contexts, edge cases).
        let blob = IOPSCopyPowerSourcesInfo()?;

        // SAFETY: `blob` was just produced by IOPSCopyPowerSourcesInfo,
        // which is the documented input contract for IOPSCopyPowerSourcesList.
        let sources = unsafe { IOPSCopyPowerSourcesList(Some(&blob)) }?;

        let count = sources.count();
        if count == 0 {
            // No power sources — Mac mini, Mac Studio, Mac Pro, etc. The
            // routing engine treats `None` as "battery unknown / not
            // applicable" and falls back to other evidence.
            return None;
        }

        for i in 0..count {
            // SAFETY: `i` is in 0..count, and `sources` is the CFArray
            // returned by IOPSCopyPowerSourcesList — its elements are
            // the IOKit-owned power-source handles documented to be
            // valid for the lifetime of the array.
            let raw = unsafe { sources.value_at_index(i) };
            if raw.is_null() {
                continue;
            }
            // SAFETY: `raw` is a non-null pointer to a CFTypeRef owned
            // by `sources`; the borrow is bounded by `sources`'s scope.
            let ps: &CFType = unsafe { &*(raw as *const CFType) };

            // SAFETY: `blob` and `ps` came from the matching pair of
            // IOPSCopyPowerSourcesInfo / IOPSCopyPowerSourcesList calls
            // above — the documented preconditions for
            // IOPSGetPowerSourceDescription.
            let Some(desc) = (unsafe { IOPSGetPowerSourceDescription(Some(&blob), Some(ps)) })
            else {
                continue;
            };

            // Some power sources (UPS, keyboard battery, etc.) may omit
            // capacity keys. Skip rather than fail — the next source
            // might be the laptop's internal battery.
            let Some(current) = lookup_int(&desc, kIOPSCurrentCapacityKey) else {
                continue;
            };
            let Some(max) = lookup_int(&desc, kIOPSMaxCapacityKey) else {
                continue;
            };
            if let Some(pct) = compute_pct(current, max) {
                return Some(pct);
            }
        }
        None
    }

    #[cfg(target_os = "macos")]
    fn lookup_int(dict: &CFDictionary, key_cstr: &CStr) -> Option<i64> {
        // IOKit defines its dictionary keys as C strings (e.g.
        // `kIOPSCurrentCapacityKey == "Current Capacity"`). The
        // dictionary itself stores CFString keys, so we wrap before
        // lookup. UTF-8 conversion never fails for these constants but
        // we propagate the Result for hygiene.
        let key_str = key_cstr.to_str().ok()?;
        let cf_key = CFString::from_str(key_str);
        let key_ptr: *const c_void = (&*cf_key as *const CFString).cast();

        // SAFETY: `key_ptr` points to a live CFString (held by
        // `cf_key`), and `dict` is a power-source description
        // dictionary with CFString keys — equality uses CFEqual.
        let raw = unsafe { dict.value(key_ptr) };
        if raw.is_null() {
            return None;
        }

        // SAFETY: `raw` is a non-null CFTypeRef value owned by `desc`
        // (which the caller holds alive); converting to `&CFType` for
        // a runtime type-check is the documented pattern.
        let cf: &CFType = unsafe { &*(raw as *const CFType) };
        let num = cf.downcast_ref::<CFNumber>()?;
        num.as_i64()
    }

    /// Map IOKit `(current, max)` capacities to a 0..=100 charge percent.
    ///
    /// Returns `None` if `max <= 0` (would divide by zero, indicates a
    /// sensor glitch or uninitialized source) or `current < 0`.
    /// Otherwise clamps to 0..=100 — some power sources briefly report
    /// `current > max` while recalibrating.
    #[cfg(target_os = "macos")]
    fn compute_pct(current: i64, max: i64) -> Option<u8> {
        if max <= 0 || current < 0 {
            return None;
        }
        // saturating_mul guards against pathological values from a
        // misbehaving driver — the division by `max > 0` then yields
        // a sane, in-range number after the clamp.
        let raw = current.saturating_mul(100) / max;
        Some(raw.clamp(0, 100) as u8)
    }

    #[cfg(test)]
    mod tests {
        use super::*;

        #[test]
        fn thermal_mapping_matches_apple_constants() {
            assert_eq!(thermal_from_nsprocessinfo(0), ThermalState::Normal);
            assert_eq!(thermal_from_nsprocessinfo(1), ThermalState::Warm);
            assert_eq!(thermal_from_nsprocessinfo(2), ThermalState::Hot);
            assert_eq!(thermal_from_nsprocessinfo(3), ThermalState::Critical);
        }

        #[test]
        fn thermal_mapping_unknown_falls_back_to_normal() {
            // Apple has only ever shipped 0..=3 for NSProcessInfoThermalState.
            // A future addition shouldn't crash inference; Normal is the
            // safest default (won't trigger should_throttle).
            assert_eq!(thermal_from_nsprocessinfo(99), ThermalState::Normal);
            assert_eq!(thermal_from_nsprocessinfo(-1), ThermalState::Normal);
        }

        #[test]
        fn read_thermal_state_does_not_panic() {
            // Smoke test: NSProcessInfo always exists and thermalState
            // always returns a valid value on macOS. Just verify the FFI
            // call returns something well-formed.
            let state = read_thermal_state();
            // All four variants are valid; we just want to confirm the
            // call returned without panicking.
            let _ = state;
        }

        #[cfg(target_os = "macos")]
        #[test]
        fn compute_pct_handles_normal_values() {
            assert_eq!(compute_pct(0, 100), Some(0));
            assert_eq!(compute_pct(50, 100), Some(50));
            assert_eq!(compute_pct(100, 100), Some(100));
            assert_eq!(compute_pct(75, 100), Some(75));
            // Real IOKit values (laptop battery, mAh-scale).
            assert_eq!(compute_pct(4_200, 5_000), Some(84));
        }

        #[cfg(target_os = "macos")]
        #[test]
        fn compute_pct_zero_or_negative_max_is_none() {
            assert_eq!(compute_pct(50, 0), None);
            assert_eq!(compute_pct(50, -100), None);
        }

        #[cfg(target_os = "macos")]
        #[test]
        fn compute_pct_negative_current_is_none() {
            // A negative `current` is a sensor glitch — don't fold that
            // through to should_throttle as an artificial 0%.
            assert_eq!(compute_pct(-1, 100), None);
        }

        #[cfg(target_os = "macos")]
        #[test]
        fn compute_pct_clamps_above_max() {
            // Power sources can briefly report current > max during
            // calibration. Clamp rather than reject.
            assert_eq!(compute_pct(105, 100), Some(100));
            assert_eq!(compute_pct(200, 100), Some(100));
        }

        #[cfg(target_os = "macos")]
        #[test]
        fn read_battery_pct_returns_none_or_valid_percent() {
            // Smoke test: don't assert exact values — laptops, desktops,
            // sandboxed test runners all behave differently. Just verify
            // the IOKit path returns a well-formed Option<u8>.
            if let Some(pct) = read_battery_pct() {
                assert!(pct <= 100, "battery percent out of range: {}", pct);
            }
        }
    }
}

#[cfg(target_os = "windows")]
mod windows {
    //! Windows native pollers.
    //!
    //! Battery via `GetSystemPowerStatus` — a single Win32 syscall, no
    //! fork, no COM, no WMI. Runs synchronously on the cache-miss path.
    //! Returns `SYSTEM_POWER_STATUS` whose `BatteryLifePercent` field
    //! carries the charge in 0..=100, with `BATTERY_PERCENTAGE_UNKNOWN`
    //! (255) signalling "no battery / unknown" on desktops.
    //!
    //! Thermal via WMI's `MSAcpi_ThermalZoneTemperature` (`ROOT\WMI`).
    //! Each query path — `CoInitializeEx`, `IWbemLocator::ConnectServer`,
    //! `IWbemServices::ExecQuery`, `IEnumWbemClassObject::Next` — costs
    //! milliseconds and would block whatever Tokio runtime thread the
    //! orchestrator's cache-miss happens to land on. Instead a dedicated
    //! background thread polls every [`THERMAL_POLL_INTERVAL`] and pushes
    //! results through [`super::set_thermal_state`]. The thread is
    //! spawned lazily on first refresh and lives for the process lifetime;
    //! transient WMI errors are logged and the loop continues.
    //!
    //! Devices without an ACPI thermal zone (some VMs, headless servers)
    //! return zero rows — we leave thermal state unset rather than
    //! lying with `Normal`.

    use std::sync::OnceLock;
    use std::thread;
    use std::time::Duration;

    use windows::core::{BSTR, PCWSTR};
    use windows::Win32::System::Com::{
        CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED,
    };
    use windows::Win32::System::Variant::{VariantClear, VARIANT, VT_I4, VT_UI4};
    use windows::Win32::System::Wmi::{
        IEnumWbemClassObject, IWbemClassObject, IWbemLocator, IWbemServices, WbemLocator,
        WBEM_FLAG_FORWARD_ONLY, WBEM_FLAG_RETURN_IMMEDIATELY, WBEM_INFINITE,
    };
    use windows_sys::Win32::System::Power::{GetSystemPowerStatus, SYSTEM_POWER_STATUS};

    use super::{set_battery_level, set_thermal_state, ThermalState};

    /// `SYSTEM_POWER_STATUS::BatteryLifePercent` sentinel for "unknown
    /// or no battery". Documented in the Win32 SDK; reproduced here so
    /// we don't depend on a constant that windows-sys may or may not
    /// re-export across versions.
    const BATTERY_PERCENTAGE_UNKNOWN: u8 = 255;

    /// How often the background thread re-queries WMI. Each query is
    /// a cross-apartment COM round-trip (single-digit milliseconds);
    /// the cache TTL inside `ResourceMonitor` is 500 ms so a few-second
    /// cadence keeps the thermal signal fresh enough for routing
    /// decisions without burning CPU on a tight loop. Tuned by hand
    /// against the same bands the Linux sysfs path uses.
    const THERMAL_POLL_INTERVAL: Duration = Duration::from_secs(3);

    /// VARENUM raw value for `VT_I4`. Compared against the variant tag
    /// returned by `IWbemClassObject::Get` for `CIM_UINT32` properties,
    /// which WMI marshals as a signed 32-bit integer.
    const VT_I4_RAW: u16 = VT_I4.0;
    /// VARENUM raw value for `VT_UI4`. Some WMI providers report
    /// `CIM_UINT32` values directly as `VT_UI4` instead of `VT_I4`;
    /// accept both rather than dropping the reading.
    const VT_UI4_RAW: u16 = VT_UI4.0;

    pub(super) fn refresh() {
        if let Some(pct) = read_battery_pct() {
            set_battery_level(pct);
        }
        ensure_thermal_poller();
    }

    fn read_battery_pct() -> Option<u8> {
        // SAFETY: GetSystemPowerStatus writes a SYSTEM_POWER_STATUS into
        // the provided pointer and returns BOOL. Zero-initializing the
        // struct first ensures every field is valid even on the
        // never-observed-but-documented case where the OS leaves a field
        // untouched. The call has no caller-side preconditions and is
        // thread-safe per Microsoft docs.
        let mut status: SYSTEM_POWER_STATUS = unsafe { std::mem::zeroed() };
        let ok = unsafe { GetSystemPowerStatus(&mut status) };
        if ok == 0 {
            return None;
        }
        battery_pct_from_status(status.BatteryLifePercent)
    }

    /// Map raw `BatteryLifePercent` to our 0..=100 representation.
    /// 255 (BATTERY_PERCENTAGE_UNKNOWN) and any out-of-range value
    /// surface as `None`; everything else clamps into u8.
    fn battery_pct_from_status(raw: u8) -> Option<u8> {
        if raw == BATTERY_PERCENTAGE_UNKNOWN || raw > 100 {
            None
        } else {
            Some(raw)
        }
    }

    /// Map deci-Kelvin (the unit `MSAcpi_ThermalZoneTemperature` reports)
    /// to a [`ThermalState`] using the same bands the Linux sysfs path
    /// uses. The conversion is `(dK / 10) - 273.15`.
    fn thermal_from_dk(deci_kelvin: u32) -> ThermalState {
        let celsius = (deci_kelvin as f32 / 10.0) - 273.15;
        if celsius >= 80.0 {
            ThermalState::Critical
        } else if celsius >= 70.0 {
            ThermalState::Hot
        } else if celsius >= 60.0 {
            ThermalState::Warm
        } else {
            ThermalState::Normal
        }
    }

    /// Spawn the WMI thermal poller exactly once per process. Subsequent
    /// calls are O(1) and do not touch COM. Spawn failure is logged and
    /// the routing engine continues with `thermal_state = None` — a
    /// degraded but non-fatal mode.
    fn ensure_thermal_poller() {
        static POLLER: OnceLock<()> = OnceLock::new();
        POLLER.get_or_init(|| {
            let spawn = thread::Builder::new()
                .name("xybrid-wmi-thermal".into())
                .spawn(thermal_poller_main);
            if let Err(err) = spawn {
                log::warn!("xybrid-wmi-thermal: failed to spawn poller thread: {err}");
            }
        });
    }

    fn thermal_poller_main() {
        // SAFETY: `CoInitializeEx` runs exactly once on this dedicated
        // thread, before any other COM call. `COINIT_MULTITHREADED`
        // matches the WMI client model — we never marshal proxies into
        // another apartment. `CoUninitialize` is intentionally not
        // called: the thread runs for the process lifetime and the
        // OS reclaims COM state at exit.
        let init = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
        if init.is_err() {
            // S_FALSE here would mean COM was already initialised on
            // this thread, which can't happen for a thread we just
            // spawned — anything other than S_OK is a real failure.
            log::warn!(
                "xybrid-wmi-thermal: CoInitializeEx failed ({init:?}), thermal poller exiting"
            );
            return;
        }

        loop {
            match poll_once() {
                Ok(Some(state)) => set_thermal_state(state),
                Ok(None) => {}
                Err(err) => {
                    log::debug!("xybrid-wmi-thermal: query failed (continuing): {err:?}");
                }
            }
            thread::sleep(THERMAL_POLL_INTERVAL);
        }
    }

    fn poll_once() -> windows::core::Result<Option<ThermalState>> {
        // SAFETY: `CoCreateInstance` is the documented entry point for
        // creating an `IWbemLocator`; the windows crate enforces that
        // `T::IID` matches the requested class.
        let locator: IWbemLocator =
            unsafe { CoCreateInstance(&WbemLocator, None, CLSCTX_INPROC_SERVER)? };

        // SAFETY: `ConnectServer` accepts BSTR arguments — empty BSTRs
        // are the documented way to request defaults for user, password,
        // locale, and authority on a local connection. `ROOT\WMI` is the
        // namespace where `MSAcpi_ThermalZoneTemperature` lives.
        let services: IWbemServices = unsafe {
            locator.ConnectServer(
                &BSTR::from("ROOT\\WMI"),
                &BSTR::new(),
                &BSTR::new(),
                &BSTR::new(),
                0,
                &BSTR::new(),
                None,
            )?
        };

        // SAFETY: `ExecQuery` is the canonical fast-forward enumeration
        // entry point; `WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY`
        // is the documented combination for read-only WQL queries.
        let enumerator: IEnumWbemClassObject = unsafe {
            services.ExecQuery(
                &BSTR::from("WQL"),
                &BSTR::from("SELECT CurrentTemperature FROM MSAcpi_ThermalZoneTemperature"),
                WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY,
                None,
            )?
        };

        let mut warmest_dk: Option<u32> = None;
        loop {
            let mut row: [Option<IWbemClassObject>; 1] = [None];
            let mut returned: u32 = 0;
            // SAFETY: `Next` writes up to `row.len()` objects and stores
            // the actual count into `returned`; both pointers reference
            // stack storage that outlives the call.
            let _hr = unsafe { enumerator.Next(WBEM_INFINITE, &mut row, &mut returned) };
            if returned == 0 {
                break;
            }
            if let Some(obj) = &row[0] {
                if let Some(dk) = read_current_temperature(obj) {
                    warmest_dk = Some(warmest_dk.map_or(dk, |w| w.max(dk)));
                }
            }
        }

        Ok(warmest_dk.map(thermal_from_dk))
    }

    /// Read the `CurrentTemperature` property from a single WMI row.
    /// Returns `None` if the property is missing, has an unexpected
    /// VARIANT type, or `Get` itself fails — any of which we treat as
    /// "skip this zone" rather than failing the whole poll.
    fn read_current_temperature(obj: &IWbemClassObject) -> Option<u32> {
        let name: [u16; 19] = [
            b'C' as u16,
            b'u' as u16,
            b'r' as u16,
            b'r' as u16,
            b'e' as u16,
            b'n' as u16,
            b't' as u16,
            b'T' as u16,
            b'e' as u16,
            b'm' as u16,
            b'p' as u16,
            b'e' as u16,
            b'r' as u16,
            b'a' as u16,
            b't' as u16,
            b'u' as u16,
            b'r' as u16,
            b'e' as u16,
            0,
        ];
        let mut value = VARIANT::default();
        // SAFETY: `name` is a UTF-16 null-terminated string with stable
        // backing storage for the duration of the call; `value` is a
        // freshly-zeroed VARIANT. `ptype`/`plflavor` are optional and
        // we don't need either.
        let res = unsafe { obj.Get(PCWSTR(name.as_ptr()), 0, &mut value, None, None) };
        if res.is_err() {
            return None;
        }
        // SAFETY: VARIANT layout is `vt` followed by a union of value
        // arms; we read the union arm matching the tag we just inspected.
        // CIM_UINT32 is documented to marshal as VT_I4 but some providers
        // return VT_UI4 — accept both.
        let extracted = unsafe {
            let inner = &value.Anonymous.Anonymous;
            match inner.vt.0 {
                VT_I4_RAW => Some(inner.Anonymous.lVal as u32),
                VT_UI4_RAW => Some(inner.Anonymous.ulVal),
                _ => None,
            }
        };
        // SAFETY: `value` is a VARIANT we own; `VariantClear` releases
        // any allocations the marshaller attached (BSTRs, IUnknowns).
        // Ignoring the result mirrors how the windows-rs samples
        // handle the cleanup path.
        let _ = unsafe { VariantClear(&mut value) };
        extracted
    }

    #[cfg(test)]
    mod tests {
        use super::*;

        #[test]
        fn raw_battery_in_range_round_trips() {
            assert_eq!(battery_pct_from_status(0), Some(0));
            assert_eq!(battery_pct_from_status(50), Some(50));
            assert_eq!(battery_pct_from_status(100), Some(100));
        }

        #[test]
        fn unknown_sentinel_maps_to_none() {
            assert_eq!(battery_pct_from_status(BATTERY_PERCENTAGE_UNKNOWN), None);
        }

        #[test]
        fn out_of_range_maps_to_none() {
            // 101..=254 is not a documented value but Microsoft's
            // BatteryLifePercent field is u8 so the OS could theoretically
            // hand us anything. Treating these as "unknown" rather than
            // clamping prevents lying to should_throttle().
            assert_eq!(battery_pct_from_status(101), None);
            assert_eq!(battery_pct_from_status(200), None);
            assert_eq!(battery_pct_from_status(254), None);
        }

        #[test]
        fn read_battery_pct_does_not_panic() {
            // Smoke test: GetSystemPowerStatus is always callable on
            // every supported Windows version. We can't assert the
            // exact value (depends on host hardware) but we can verify
            // it returns a well-formed Option<u8>.
            if let Some(pct) = read_battery_pct() {
                assert!(pct <= 100, "battery percent out of range: {}", pct);
            }
        }

        #[test]
        fn deci_kelvin_bands_match_thermal_state_docs() {
            // Conversion: dK / 10 - 273.15 → °C. The band cutoffs are
            // 60/70/80 °C, which fall between integer dK values
            // (60.0 °C = 3331.5 dK), so the closest integer dK on
            // either side is the strongest boundary check available.
            assert_eq!(thermal_from_dk(2731), ThermalState::Normal); // 0.0 °C
            assert_eq!(thermal_from_dk(3231), ThermalState::Normal); // 50.0 °C
            assert_eq!(thermal_from_dk(3331), ThermalState::Normal); // 59.95 °C
            assert_eq!(thermal_from_dk(3332), ThermalState::Warm); // 60.05 °C
            assert_eq!(thermal_from_dk(3431), ThermalState::Warm); // 69.95 °C
            assert_eq!(thermal_from_dk(3432), ThermalState::Hot); // 70.05 °C
            assert_eq!(thermal_from_dk(3531), ThermalState::Hot); // 79.95 °C
            assert_eq!(thermal_from_dk(3532), ThermalState::Critical); // 80.05 °C
            assert_eq!(thermal_from_dk(3731), ThermalState::Critical); // 100.0 °C
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    // Tests touch a single process-wide global. Serialize them so parallel
    // execution doesn't see crossed writes.
    static TEST_LOCK: Mutex<()> = Mutex::new(());

    fn reset() {
        set_platform_state(PlatformState::EMPTY);
    }

    #[test]
    fn empty_state_when_nothing_pushed() {
        let _g = TEST_LOCK.lock().unwrap();
        reset();
        let s = current_platform_state();
        assert_eq!(s.battery_pct, None);
        assert_eq!(s.thermal_state, None);
    }

    #[test]
    fn set_and_clear_battery() {
        let _g = TEST_LOCK.lock().unwrap();
        reset();
        set_battery_level(75);
        assert_eq!(current_platform_state().battery_pct, Some(75));
        clear_battery_level();
        assert_eq!(current_platform_state().battery_pct, None);
    }

    #[test]
    fn set_and_clear_thermal() {
        let _g = TEST_LOCK.lock().unwrap();
        reset();
        set_thermal_state(ThermalState::Hot);
        assert_eq!(
            current_platform_state().thermal_state,
            Some(ThermalState::Hot)
        );
        clear_thermal_state();
        assert_eq!(current_platform_state().thermal_state, None);
    }

    #[test]
    fn set_battery_clamps_to_100() {
        let _g = TEST_LOCK.lock().unwrap();
        reset();
        set_battery_level(255);
        assert_eq!(current_platform_state().battery_pct, Some(100));
    }

    #[test]
    fn whole_struct_push_replaces_all_fields() {
        let _g = TEST_LOCK.lock().unwrap();
        reset();
        set_battery_level(40);
        set_thermal_state(ThermalState::Warm);
        set_platform_state(PlatformState {
            battery_pct: Some(80),
            thermal_state: None,
        });
        let s = current_platform_state();
        assert_eq!(s.battery_pct, Some(80));
        assert_eq!(s.thermal_state, None);
    }

    #[test]
    fn battery_and_thermal_are_independent() {
        let _g = TEST_LOCK.lock().unwrap();
        reset();
        set_battery_level(50);
        set_thermal_state(ThermalState::Warm);
        clear_battery_level();
        let s = current_platform_state();
        assert_eq!(s.battery_pct, None);
        assert_eq!(s.thermal_state, Some(ThermalState::Warm));
    }

    #[test]
    fn resource_monitor_snapshot_reflects_pushed_state() {
        // End-to-end check: a host push appears on the next
        // ResourceMonitor cache miss. Uses `Duration::ZERO` to force a
        // refresh past the TTL.
        //
        // On platforms with an active native poller (Linux sysfs, macOS
        // NSProcessInfo + pmset), `refresh_locked` will overwrite host
        // pushes with the native readings, so the exact-value
        // assertions only run where no native source competes.
        use crate::device::ResourceMonitor;
        use std::time::Duration;

        let _g = TEST_LOCK.lock().unwrap();
        reset();

        let monitor = ResourceMonitor::new();

        set_battery_level(42);
        set_thermal_state(ThermalState::Hot);

        let after = monitor.current_snapshot(Duration::ZERO);

        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
        {
            // No in-process native poller — the host push is the only
            // source and must round-trip exactly.
            assert_eq!(after.battery_pct, Some(42));
            assert_eq!(after.thermal_state, ThermalState::Hot);
        }
        #[cfg(any(target_os = "linux", target_os = "macos"))]
        {
            // Native poller may have overwritten the host push with
            // real sysfs / Foundation readings. We can't assert exact
            // values without mocking the platform; assert the overlay
            // path executed without crashing and produced well-formed
            // values.
            assert!(
                after.battery_pct.map(|p| p <= 100).unwrap_or(true),
                "battery_pct out of range: {:?}",
                after.battery_pct
            );
            // thermal_state is an enum, any variant is well-formed; the
            // bind silences unused-variable warnings while still
            // exercising the overlay.
            let _ = after.thermal_state;
        }

        reset();
    }
}