//! System inventory collection for hello-agent (CMDB).
//!
//! Collects hardware and OS metadata at startup — BIOS serial number,
//! manufacturer / model, AD domain, OS edition + release, CPU details,
//! RAM, disks, and the BitLocker recovery key for the system drive — and
//! returns it as a compact JSON object. The caller stamps the result into
//! `hbb_common::config::INVENTORY` so the next /api/sysinfo upload carries
//! it under the `inventory` key. The rustdesk-server admin UI's per-device
//! detail page reads it back from `device_sysinfo.payload`.
//!
//! Implementation: a single PowerShell child gathers everything via
//! Get-CimInstance and emits compact JSON. One subprocess for the whole
//! inventory is cheaper than per-field WMI queries and avoids pulling a
//! `wmi`/COM crate into the dep tree. Inventory is collected once at
//! startup. Collection routinely outruns the first sysinfo tick (TIME_CONN
//! = 3 s) — `Invoke-RestMethod 'api.ipify.org' -TimeoutSec 5` alone can
//! burn that budget on hosts with blocked egress — so the sysinfo loop in
//! `hbbs_http::sync` watches for INVENTORY transitioning empty → populated
//! and forces a re-upload at that point. Subsequent ticks are suppressed
//! by the loop's `had_inventory` / `uploaded` bookkeeping.
//!
//! Non-Windows builds return an empty JSON object — hello-agent v0 only
//! ships on Windows, but keeping the cross-platform surface compiling
//! makes future Linux work cheap.

#[cfg(target_os = "windows")]
const PS_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$bios = Get-CimInstance -ClassName Win32_BIOS
$cs   = Get-CimInstance -ClassName Win32_ComputerSystem
$os   = Get-CimInstance -ClassName Win32_OperatingSystem
$cpus = @(Get-CimInstance -ClassName Win32_Processor)
$first_cpu = $cpus | Select-Object -First 1
$total_phys_cores = ($cpus | Measure-Object -Property NumberOfCores -Sum).Sum
$total_log_cores  = ($cpus | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
$disks = @(Get-CimInstance -ClassName Win32_DiskDrive | ForEach-Object {
    [pscustomobject]@{
        name    = $_.DeviceID
        model   = $_.Model
        size_gb = if ($_.Size) { [math]::Round([double]$_.Size / 1GB, 1) } else { 0 }
        media   = $_.MediaType
    }
})
# DisplayVersion is the marketing release ID (e.g. "23H2") — not surfaced
# by Win32_OperatingSystem.Version, which only carries the build number.
$displayVersion = ''
try {
    $displayVersion = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
} catch {}
# BitLocker recovery key for the system drive. Get-BitLockerVolume needs
# the BitLocker PowerShell module (present on Pro/Enterprise SKUs) and
# admin rights; the agent runs as LocalSystem in production. On Home SKUs
# or Linux this just stays empty.
$bl_key = ''
try {
    $sysDrive = $env:SystemDrive
    $bv = Get-BitLockerVolume -MountPoint $sysDrive -ErrorAction Stop
    $kp = $bv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1
    if ($kp -and $kp.RecoveryPassword) { $bl_key = $kp.RecoveryPassword }
} catch {}

# Network interfaces: every adapter (Up + Disabled + Disconnected) with
# its MAC and bound IPv4/IPv6 addresses. Filtering happens server-side
# so the operator can still see "this NIC is disabled" rather than
# silently dropping it. LinkSpeed comes back as a string like "1 Gbps"
# or "100 Mbps" — normalize to integer Mbps, 0 when unknown / down.
$nics = @(Get-NetAdapter | ForEach-Object {
    $nic = $_
    $ipv4 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
    $ipv6 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
    $speed_mbps = 0
    if ($nic.LinkSpeed) {
        if ($nic.LinkSpeed -match '^([\d.]+)\s*Gbps') { $speed_mbps = [int]([double]$Matches[1] * 1000) }
        elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Mbps') { $speed_mbps = [int]([double]$Matches[1]) }
        elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Kbps') { $speed_mbps = 0 }
    }
    [pscustomobject]@{
        name        = $nic.Name
        description = $nic.InterfaceDescription
        mac         = $nic.MacAddress
        status      = "$($nic.Status)"
        ipv4        = $ipv4
        ipv6        = $ipv6
        speed_mbps  = $speed_mbps
        is_wifi     = ($nic.PhysicalMediaType -eq 'Native 802.11')
    }
})

# Wi-Fi inventory is collected separately, in Rust, against the Win32
# Native Wi-Fi API (`wlanapi.dll`). netsh's text output is partially
# localized — `Authentication` becomes `Authentifizierung` /
# `Autenticación` / `Autentificare` on de/es/ro Windows — and our
# regexes silently dropped fields on non-English hosts. The native API
# returns SSIDs as bytes and auth/cipher as numeric enums, so the
# resulting data is locale-stable. See `src/wifi_native.rs`. The fields
# `wifi_current` / `wifi_nearby` are merged into this object after
# PowerShell exits.

# Public egress IP: best-effort lookup against an external echo service.
# Used when the operator wants to correlate the device with a NAT'd
# location. 5 s timeout so a blocked corporate firewall doesn't stall
# inventory collection. ipify is HTTPS, IPv4-only by default, no auth.
$public_ip = ''
try {
    $public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
} catch {}

# Installed software (the "Add/Remove Programs" / "Apps & features" list).
# We enumerate the Uninstall registry keys directly — the same source
# Settings reads — rather than `Get-CimInstance Win32_Product`, which is
# notoriously slow (triggers MSI self-repair on every entry) and only
# covers MSI-installed software, missing everything from per-user
# installers, Chocolatey/Scoop/Inno-Setup, etc.
#
# We read both HKLM hives (64-bit + WOW6432Node 32-bit) so apps installed
# under either bitness show up. HKCU is skipped on purpose: the agent
# runs as LocalSystem (or LocalService), whose HKCU hive has nothing the
# logged-in user installed under their own profile — that data would
# require running per-user, which is out of scope for v1.
#
# Filter rules:
#   * `DisplayName` must be set — empty-DisplayName entries are uninstall
#     stubs for individual update KBs and not user-facing apps.
#   * Skip `SystemComponent = 1` — internal Windows components hidden
#     from Settings (DirectX shims, VC++ private redists, …).
#   * Skip entries with a `ParentKeyName` — those are subcomponents of a
#     parent application (e.g. Office's per-language packs); the parent
#     row already covers the user-facing app.
#
# Bitness-tagged so the admin UI can distinguish a 64-bit vs 32-bit
# install of the same product (common for runtimes like VC++).
$installed_software = @()
foreach ($scope in @(
    @{ path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*';                bitness = '64' },
    @{ path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*';    bitness = '32' }
)) {
    try {
        $entries = Get-ItemProperty -Path $scope.path -ErrorAction SilentlyContinue
    } catch { continue }
    foreach ($e in $entries) {
        if (-not $e.DisplayName)        { continue }
        if ($e.SystemComponent -eq 1)   { continue }
        if ($e.ParentKeyName)           { continue }
        $installed_software += [pscustomobject]@{
            name         = "$($e.DisplayName)"
            version      = if ($e.DisplayVersion) { "$($e.DisplayVersion)" } else { '' }
            publisher    = if ($e.Publisher)      { "$($e.Publisher)"      } else { '' }
            install_date = if ($e.InstallDate)    { "$($e.InstallDate)"    } else { '' }
            bitness      = $scope.bitness
        }
    }
}
$installed_software = @($installed_software | Sort-Object name, version)

$os_release = "$($os.Version)"
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
$result = [pscustomobject]@{
    serial_number          = $bios.SerialNumber
    manufacturer           = $cs.Manufacturer
    model                  = $cs.Model
    domain                 = $cs.Domain
    os_distro              = $os.Caption
    os_release             = $os_release
    cpu_model              = $first_cpu.Name
    cpu_speed_ghz          = if ($first_cpu.MaxClockSpeed) { [math]::Round([double]$first_cpu.MaxClockSpeed / 1000, 2) } else { 0 }
    cpu_cores_physical     = $total_phys_cores
    cpu_cores_logical      = $total_log_cores
    ram_gb                 = if ($cs.TotalPhysicalMemory) { [math]::Round([double]$cs.TotalPhysicalMemory / 1GB, 1) } else { 0 }
    disks                  = $disks
    bitlocker_recovery_key = $bl_key
    network_interfaces     = $nics
    public_ip              = $public_ip
    installed_software     = $installed_software
}
$result | ConvertTo-Json -Compress -Depth 6
"#;

/// Collect the inventory and return it as a JSON string. Empty string on
/// any failure — the caller treats that as "skip this upload's
/// `inventory` field" rather than uploading garbage.
#[cfg(target_os = "windows")]
pub fn collect_inventory() -> String {
    use std::os::windows::process::CommandExt;
    use std::process::Command;
    // CREATE_NO_WINDOW prevents a brief PowerShell console flash if the
    // agent is ever run interactively (dev mode). In service mode there's
    // no console anyway, but the flag is harmless.
    const CREATE_NO_WINDOW: u32 = 0x08000000;

    let output = match Command::new("powershell.exe")
        .args([
            "-NoProfile",
            "-NonInteractive",
            "-ExecutionPolicy",
            "Bypass",
            "-Command",
            PS_SCRIPT,
        ])
        .creation_flags(CREATE_NO_WINDOW)
        .output()
    {
        Ok(o) => o,
        Err(e) => {
            log::warn!("inventory: powershell failed to spawn: {e}");
            return String::new();
        }
    };

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        log::warn!(
            "inventory: powershell exited non-zero ({:?}): {}",
            output.status.code(),
            stderr.trim()
        );
        return String::new();
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        log::warn!("inventory: powershell produced empty stdout");
        return String::new();
    }

    // Parse the PowerShell-produced object so we can merge in the
    // native Wi-Fi data below. Any parse failure aborts the whole
    // collection — sync.rs would otherwise have to reject malformed
    // payloads mid-upload and we'd carry the bad data until the next
    // collect.
    let mut value: serde_json::Value = match serde_json::from_str(trimmed) {
        Ok(v) => v,
        Err(e) => {
            log::warn!(
                "inventory: powershell output is not valid JSON: {e}; first 200 chars: {:.200}",
                trimmed
            );
            return String::new();
        }
    };

    // Native Wi-Fi via wlanapi.dll. Emits SSIDs as bytes and auth/cipher
    // as numeric enums, so the result is locale-stable across the
    // en/de/es/ro Windows builds in our fleet — replaces the
    // previously-localized netsh parser.
    let (wifi_current, wifi_nearby) = crate::wifi_native::collect();
    if let Some(c) = wifi_current {
        value["wifi_current"] = c;
    }
    value["wifi_nearby"] = serde_json::Value::Array(wifi_nearby);

    let serialized = value.to_string();
    log::info!("inventory: collected ({} bytes)", serialized.len());
    serialized
}

#[cfg(not(target_os = "windows"))]
pub fn collect_inventory() -> String {
    String::new()
}
