modules/HypervisorInventory/HypervisorInventory.psm1

<#
    HypervisorInventory.psm1 - imports an externally supplied hypervisor inventory (a VMware /
    vCenter export, an RMM VM list, a spreadsheet) for decommissioning analysis. Read-only: it
    reads ONE local file named by -HypervisorInventoryPath and never connects to a hypervisor,
    so no hypervisor credential is ever needed or stored.

    Produces: ExternalHypervisorVMs, ExternalHypervisorDatastores, ExternalHypervisorFiles,
              HypervisorInventorySummary.

    Input: CSV or JSON (array of objects). Columns (case-insensitive; unknown columns DROPPED so
    free-text fields such as VM notes never reach the output):
      RecordType VM (default) | Datastore | File
      VM rows: VMName*, PowerState*, HostName, Cluster, Datastore, VMPath, GuestOS,
                      GuestHostName, CpuCount, MemoryGB, ProvisionedGB, UsedGB, SnapshotCount,
                      IsoAttached, Source
      Datastore rows: Name*, CapacityGB, FreeGB, HostName, Source
      File rows: Path*, Datastore, FileType, SizeGB, IsOrphaned, Source
    'Source' names where a row came from (e.g. 'vCenter', 'RMM'); when two sources disagree on
    the VM count for a host, that is reported as a count conflict.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='HypervisorInventory'; DisplayName='Hypervisor Inventory Import (VMware / RMM)'; Category='Virtualization'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Minimal'; CanRunAsSystem=$true
        ProducesDatasets=@('ExternalHypervisorVMs','ExternalHypervisorDatastores','ExternalHypervisorFiles','HypervisorInventorySummary')
        ProducesRisks=$true; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$false
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    $p = [string]$Context.Parameters['HypervisorInventoryPath']
    if ($p -and (Test-Path -LiteralPath $p -PathType Leaf)) { return [pscustomobject]@{ ModuleName='HypervisorInventory'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } }
    if ($p) { return [pscustomobject]@{ ModuleName='HypervisorInventory'; CanRun=$false; Status='NotApplicable'; Reason=("Hypervisor inventory file not found: {0}" -f $p); Limitations=@(("-HypervisorInventoryPath '{0}' does not exist." -f $p)) } }
    [pscustomobject]@{ ModuleName='HypervisorInventory'; CanRun=$false; Status='NotApplicable'; Reason='No -HypervisorInventoryPath supplied.'; Limitations=@() }
}

$script:HvColumns = @{
    VM        = @('VMName','PowerState','HostName','Cluster','Datastore','VMPath','GuestOS','GuestHostName','CpuCount','MemoryGB','ProvisionedGB','UsedGB','SnapshotCount','IsoAttached','Source')
    Datastore = @('Name','CapacityGB','FreeGB','HostName','Source')
    File      = @('Path','Datastore','FileType','SizeGB','IsOrphaned','Source')
}

function ConvertTo-HypervisorInventory {
    <# Pure: raw imported rows -> @{ VMs; Datastores; Files; Rejected } keeping only schema columns. #>
    param([object[]]$Rows = @())
    $out = @{ VM = [System.Collections.Generic.List[object]]::new(); Datastore = [System.Collections.Generic.List[object]]::new(); File = [System.Collections.Generic.List[object]]::new(); Rejected = 0 }
    foreach ($r in $Rows) {
        if ($null -eq $r) { continue }
        $props = @{}; foreach ($p in $r.PSObject.Properties) { $props[$p.Name.ToLowerInvariant()] = $p.Value }
        $type = [string]$props['recordtype']; if (-not $type) { $type = 'VM' }
        $type = (Get-Culture).TextInfo.ToTitleCase($type.ToLowerInvariant())
        if (-not $script:HvColumns.ContainsKey($type)) { $out.Rejected++; continue }
        $row = [ordered]@{}
        foreach ($c in $script:HvColumns[$type]) { $row[$c] = $props[$c.ToLowerInvariant()] }
        $key = $script:HvColumns[$type][0]
        if (-not $row[$key]) { $out.Rejected++; continue }
        if ($type -eq 'VM') {
            $state = [string]$row['PowerState']
            $row['IsRunning'] = [bool]($state -match '(?i)^(on|poweredon|running)$')
            $row['IsPoweredOff'] = [bool]($state -match '(?i)^(off|poweredoff|stopped|saved|suspended)$')
            $snap = 0; [void][int]::TryParse([string]$row['SnapshotCount'], [ref]$snap); $row['HasSnapshots'] = [bool]($snap -gt 0)
        }
        if ($type -eq 'File') { $row['IsIso'] = [bool]([string]$row['Path'] -match '(?i)\.iso$' -or [string]$row['FileType'] -match '(?i)iso') }
        $out[$type].Add([pscustomobject]$row)
    }
    return $out
}

function Get-HypervisorCountConflicts {
    <# Pure: per host, VM counts by Source; returns one row per host where two or more sources disagree. #>
    param([object[]]$VMs = @())
    $rows = foreach ($g in (@($VMs | Where-Object { $_.Source }) | Group-Object HostName)) {
        $bySource = @($g.Group | Group-Object Source | ForEach-Object { [pscustomobject]@{ Source = $_.Name; Count = $_.Count } })
        if ($bySource.Count -lt 2) { continue }
        if (@($bySource | ForEach-Object { $_.Count } | Select-Object -Unique).Count -le 1) { continue }   # sources agree
        [pscustomobject]@{ HostName=$g.Name; CountsBySource=((@($bySource | ForEach-Object { '{0}={1}' -f $_.Source, $_.Count })) -join '; ') }
    }
    return ,@($rows)
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $path = [string]$Context.Parameters['HypervisorInventoryPath']
    $raw = @()
    try {
        if ($path -match '(?i)\.json$') { $parsed = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json; $raw = @($parsed) }
        else { $raw = @(Import-Csv -LiteralPath $path -ErrorAction Stop) }
    } catch {
        Add-Limitation -Context $Context -Module 'HypervisorInventory' -Message ("Hypervisor inventory file could not be parsed: {0}" -f $path) -Reason $_.Exception.Message | Out-Null
        return ,@{ ExternalHypervisorVMs=@(); ExternalHypervisorDatastores=@(); ExternalHypervisorFiles=@(); HypervisorInventorySummary=@() }
    }
    $inv = ConvertTo-HypervisorInventory -Rows $raw
    if ($inv.Rejected -gt 0) { Add-Limitation -Context $Context -Module 'HypervisorInventory' -Message ("{0} imported row(s) were skipped (unknown RecordType or missing key column)." -f $inv.Rejected) | Out-Null }
    $vms = @($inv.VM); $files = @($inv.File)
    $conflicts = Get-HypervisorCountConflicts -VMs $vms
    $summary = @([pscustomobject]@{
        SourceFile=(Split-Path -Leaf $path); Sources=((@($vms | ForEach-Object { [string]$_.Source } | Where-Object { $_ } | Select-Object -Unique)) -join ', ')
        Hosts=((@($vms | ForEach-Object { [string]$_.HostName } | Where-Object { $_ } | Select-Object -Unique)) -join ', ')
        RegisteredVMs=$vms.Count; RunningVMs=@($vms | Where-Object { $_.IsRunning }).Count; PoweredOffVMs=@($vms | Where-Object { $_.IsPoweredOff }).Count
        VMsWithSnapshots=@($vms | Where-Object { $_.HasSnapshots }).Count; Datastores=@($inv.Datastore).Count
        IsoFiles=@($files | Where-Object { $_.IsIso }).Count; OrphanedFiles=@($files | Where-Object { [string]$_.IsOrphaned -match '(?i)^(true|yes|1)$' }).Count
        CountConflicts=@($conflicts).Count; CountConflictDetail=((@($conflicts | ForEach-Object { '{0}: {1}' -f $_.HostName, $_.CountsBySource })) -join ' | ')
        Note='Imported from an external file; accuracy is only as good as that export and its date.'
    })
    return ,@{ ExternalHypervisorVMs=$vms; ExternalHypervisorDatastores=@($inv.Datastore); ExternalHypervisorFiles=$files; HypervisorInventorySummary=$summary }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } }
    Add-DataSet -Context $Context -Name 'ExternalHypervisorVMs'        -Description 'VMs from an imported hypervisor/RMM inventory file.' -Rows (& $get 'ExternalHypervisorVMs') -Visibility 'Internal' -SourceModule 'HypervisorInventory' | Out-Null
    Add-DataSet -Context $Context -Name 'ExternalHypervisorDatastores' -Description 'Datastores from an imported hypervisor inventory file.' -Rows (& $get 'ExternalHypervisorDatastores') -Visibility 'Internal' -SourceModule 'HypervisorInventory' | Out-Null
    Add-DataSet -Context $Context -Name 'ExternalHypervisorFiles'      -Description 'Datastore files (ISOs, orphaned disks) from an imported inventory file.' -Rows (& $get 'ExternalHypervisorFiles') -Visibility 'Internal' -SourceModule 'HypervisorInventory' | Out-Null
    Add-DataSet -Context $Context -Name 'HypervisorInventorySummary'   -Description 'Imported hypervisor inventory summary, incl. VM-count conflicts between sources.' -Rows (& $get 'HypervisorInventorySummary') -Visibility 'Internal' -SourceModule 'HypervisorInventory' | Out-Null
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','ConvertTo-HypervisorInventory','Get-HypervisorCountConflicts'