Public/Sync-VMTags.ps1

function Sync-VMTags {
    <#
    .SYNOPSIS
        Automatically applies tags to VMs based on their metadata and a tag profile.
    .DESCRIPTION
        The main workhorse of VM-AutoTagger. Connects to a vCenter Server, ESXi host,
        or Hyper-V host, enumerates virtual machines, collects metadata (OS, CPU, RAM,
        snapshots, tools status, network, datastore, age, power state), and applies tags
        according to a customizable YAML tag profile.
 
        Tags are organized into categories (OS-Family, CPU-Tier, Memory-Tier, etc.) and
        each VM receives one tag per category (or multiple for categories like Network and
        Datastore that support multiple cardinality).
 
        For VMware vSphere, tags are applied as native vSphere tags via PowerCLI.
        For Microsoft Hyper-V, tags are stored as a structured JSON block in the VM Notes
        field (since Hyper-V does not have a native tag system).
 
        Supports filtering by VM name (with wildcards), cluster, and datacenter. DryRun
        mode shows what would be applied without making changes. Force mode overwrites
        existing tags even when they already match.
 
        After each sync, a state file is saved for drift detection by Get-VMDrift.
    .PARAMETER Server
        The vCenter Server, ESXi host, or Hyper-V hostname/IP to connect to.
    .PARAMETER Credential
        PSCredential for authentication. If omitted, uses the current session
        or prompts for credentials.
    .PARAMETER ProfilePath
        Path to a YAML tag profile file. If not specified, the built-in default profile
        is used, which covers the 10 standard tag categories.
    .PARAMETER VMName
        One or more VM names to process. Supports wildcards (e.g., "Web*", "DB-??-Prod").
        If omitted, all VMs in scope are processed.
    .PARAMETER Cluster
        Filter VMs to a specific vSphere cluster.
    .PARAMETER Datacenter
        Filter VMs to a specific vSphere datacenter.
    .PARAMETER DryRun
        Show what tags would be applied without actually making any changes. Useful for
        previewing the impact of a sync operation.
    .PARAMETER Force
        Overwrite existing tag assignments even if the current tag matches. By default,
        tags that already match are skipped for efficiency.
    .PARAMETER Hypervisor
        The hypervisor platform to connect to. Valid values: 'VMware', 'HyperV'.
        Default: 'VMware'. When set to 'HyperV', uses the Hyper-V PowerShell module
        and stores tags as JSON in VM Notes instead of native vSphere tags.
    .PARAMETER OutputPath
        Path to save an HTML summary report of the sync operation.
    .EXAMPLE
        Sync-VMTags -Server vcenter.contoso.com
 
        Connects to vCenter and applies default tag profile to all VMs.
    .EXAMPLE
        Sync-VMTags -Server vcenter.contoso.com -VMName "Web*" -DryRun -Verbose
 
        Shows what tags would be applied to VMs matching "Web*" without making changes.
    .EXAMPLE
        $cred = Get-Credential
        Sync-VMTags -Server vcenter.contoso.com -Credential $cred -ProfilePath .\Profiles\enterprise.yml -Cluster "Production" -OutputPath .\sync-report.html
 
        Syncs tags for the Production cluster using the enterprise profile and generates an HTML report.
    .EXAMPLE
        Sync-VMTags -Server hyperv01 -Hypervisor HyperV
 
        Connects to a Hyper-V host and applies tags stored as JSON in VM Notes.
    .EXAMPLE
        Sync-VMTags -Server localhost -Hypervisor HyperV -DryRun -Verbose
 
        Preview tag assignments on the local Hyper-V host without making changes.
    .NOTES
        Author: Larry Roberts
        Requires: VMware.PowerCLI module (for VMware) or Hyper-V module (for HyperV)
        Part of the VM-AutoTagger module.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Server,

        [Parameter()]
        [PSCredential]$Credential,

        [Parameter()]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$ProfilePath,

        [Parameter()]
        [string[]]$VMName,

        [Parameter()]
        [string]$Cluster,

        [Parameter()]
        [string]$Datacenter,

        [Parameter()]
        [switch]$DryRun,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [string]$OutputPath,

        [Parameter()]
        [ValidateSet('VMware', 'HyperV')]
        [string]$Hypervisor = 'VMware'
    )

    begin {
        $startTime = Get-Date
        $syncResults = [System.Collections.ArrayList]::new()
        $errors = [System.Collections.ArrayList]::new()
        $tagAssignmentCount = 0
        $tagSkippedCount = 0
        $tagCreatedCount = 0
        $categoryCreatedCount = 0
        $vmProcessedCount = 0

        Write-Verbose "VM-AutoTagger Sync starting at $startTime"
        if ($DryRun) {
            Write-Host "[DRY RUN] No changes will be made to vCenter." -ForegroundColor Cyan
        }
    }

    process {
        # --- Step 1: Connect to hypervisor ---
        Write-Verbose "Connecting to $Hypervisor host: $Server"
        $viConnection = $null
        try {
            $viConnection = Connect-Hypervisor -Server $Server -Credential $Credential -Hypervisor $Hypervisor
            if ($Hypervisor -eq 'VMware') {
                Write-Verbose "Connected to $Server (Version: $($viConnection.Version))"
            } else {
                Write-Verbose "Connected to Hyper-V host: $Server (Type: $($viConnection.Type))"
            }
        }
        catch {
            Write-Error "Failed to connect to $Hypervisor host '$Server': $_"
            return
        }

        try {
            # --- Step 2: Get VMs ---
            Write-Verbose "Retrieving virtual machines..."
            if ($Hypervisor -eq 'HyperV') {
                $getVMParams = @{ ErrorAction = 'Stop' }
                if ($Server -ne 'localhost' -and $Server -ne $env:COMPUTERNAME -and $Server -ne '.') {
                    $getVMParams['ComputerName'] = $Server
                }
                $allVMs = @(Hyper-V\Get-VM @getVMParams)
            }
            else {
                $getVMParams = @{ Server = $Server; ErrorAction = 'Stop' }

                if ($Cluster) {
                    Write-Verbose "Filtering to cluster: $Cluster"
                    $getVMParams['Location'] = Get-Cluster -Name $Cluster -Server $Server -ErrorAction Stop
                }
                elseif ($Datacenter) {
                    Write-Verbose "Filtering to datacenter: $Datacenter"
                    $getVMParams['Location'] = Get-Datacenter -Name $Datacenter -Server $Server -ErrorAction Stop
                }

                $allVMs = @(Get-VM @getVMParams)
            }

            # Apply VM name filter if specified
            if ($VMName) {
                $filteredVMs = [System.Collections.ArrayList]::new()
                foreach ($pattern in $VMName) {
                    $matched = @($allVMs | Where-Object { $_.Name -like $pattern })
                    foreach ($vm in $matched) {
                        if ($filteredVMs.Name -notcontains $vm.Name) {
                            [void]$filteredVMs.Add($vm)
                        }
                    }
                }
                $allVMs = $filteredVMs.ToArray()
            }

            Write-Verbose "Found $($allVMs.Count) VM(s) to process."
            if ($allVMs.Count -eq 0) {
                Write-Warning "No VMs found matching the specified criteria."
                return
            }

            # --- Step 3: Load tag profile ---
            $tagProfile = $null
            if ($ProfilePath) {
                Write-Verbose "Loading tag profile from: $ProfilePath"
                $tagProfile = Read-TagProfile -Path $ProfilePath
            }
            else {
                Write-Verbose "Using default tag profile."
                $tagProfile = Get-DefaultTagProfile
            }

            Write-Verbose "Profile '$($tagProfile.Name)' loaded with $($tagProfile.Categories.Count) categories."

            # --- Step 4: Ensure tag categories exist (VMware only) ---
            $categoryMap = @{}
            if ($Hypervisor -eq 'VMware') {
                foreach ($catDef in $tagProfile.Categories) {
                    if (-not $DryRun) {
                        $cat = Initialize-TagCategory -Name $catDef.Name `
                            -Cardinality $catDef.Cardinality `
                            -Description $catDef.Description

                        if ($cat) {
                            $categoryMap[$catDef.Name] = $cat
                            if ($cat.PSObject.Properties['Uid'] -and $cat.Uid) {
                                Write-Verbose "Category '$($catDef.Name)' ready."
                            }
                            else {
                                $categoryCreatedCount++
                            }
                        }
                    }
                    else {
                        Write-Host " [DRY RUN] Would ensure category: $($catDef.Name) ($($catDef.Cardinality))" -ForegroundColor DarkGray
                    }
                }
            }
            else {
                # Hyper-V: categories are implicit in the JSON tag block -- no setup needed
                if ($DryRun) {
                    foreach ($catDef in $tagProfile.Categories) {
                        Write-Host " [DRY RUN] Would use category: $($catDef.Name) (stored in VM Notes)" -ForegroundColor DarkGray
                    }
                }
            }

            # --- Step 5: Process each VM ---
            $syncState = [System.Collections.ArrayList]::new()
            $vmIndex = 0

            foreach ($vm in $allVMs) {
                $vmIndex++
                $percentComplete = [math]::Round(($vmIndex / $allVMs.Count) * 100, 0)
                Write-Progress -Activity "Syncing VM Tags" -Status "$($vm.Name) ($vmIndex of $($allVMs.Count))" -PercentComplete $percentComplete

                Write-Verbose "Processing VM: $($vm.Name) ($vmIndex/$($allVMs.Count))"

                # Collect metadata
                $metadata = $null
                try {
                    if ($Hypervisor -eq 'HyperV') {
                        $metadata = Get-HyperVMetadata -VM $vm -IncludeCreationDate
                    }
                    else {
                        $metadata = Get-VMMetadata -VM $vm -IncludeCreationDate
                    }
                }
                catch {
                    $errMsg = "Failed to collect metadata for $($vm.Name): $_"
                    Write-Warning $errMsg
                    [void]$errors.Add([PSCustomObject]@{ VM = $vm.Name; Error = $errMsg })
                    continue
                }

                $vmTagResults = [ordered]@{}

                if ($Hypervisor -eq 'HyperV') {
                    # --- Hyper-V: collect all tags, then write once to Notes ---
                    $hvTagsForVM = @{}

                    # Get current tags from Notes (for comparison)
                    $currentHVTags = @{}
                    if (-not $DryRun) {
                        try {
                            $currentHVTags = Get-HyperVNotesTags -VM $vm
                        }
                        catch {
                            Write-Verbose "Could not retrieve current tags from Notes for $($vm.Name): $_"
                        }
                    }

                    foreach ($catDef in $tagProfile.Categories) {
                        $tagValues = @(Resolve-TagValue -VMMetadata $metadata -CategoryDefinition $catDef)

                        if ($tagValues.Count -eq 0) {
                            Write-Verbose " No tag resolved for $($catDef.Name) on $($vm.Name)"
                            continue
                        }

                        foreach ($tagValue in $tagValues) {
                            if ([string]::IsNullOrWhiteSpace($tagValue)) { continue }

                            if ($DryRun) {
                                Write-Host " [DRY RUN] $($vm.Name): $($catDef.Name) = $tagValue" -ForegroundColor Yellow
                                $tagAssignmentCount++
                                $vmTagResults[$catDef.Name] = $tagValue
                                continue
                            }

                            # Check if tag already matches
                            $alreadyAssigned = $false
                            if (-not $Force -and $currentHVTags.ContainsKey($catDef.Name) -and $currentHVTags[$catDef.Name] -eq $tagValue) {
                                $alreadyAssigned = $true
                            }

                            if ($alreadyAssigned) {
                                Write-Verbose " Tag '$($catDef.Name)/$tagValue' already set on $($vm.Name) -- skipping."
                                $tagSkippedCount++
                            }
                            else {
                                $tagAssignmentCount++
                            }

                            $hvTagsForVM[$catDef.Name] = $tagValue
                            $vmTagResults[$catDef.Name] = $tagValue
                        }
                    }

                    # Write all tags at once to the VM Notes field
                    if (-not $DryRun -and $hvTagsForVM.Count -gt 0) {
                        if ($PSCmdlet.ShouldProcess("$($vm.Name): Set $($hvTagsForVM.Count) tag(s) in VM Notes", "Set HyperV Notes Tags")) {
                            try {
                                Set-HyperVNotesTags -VM $vm -Tags $hvTagsForVM -Server $Server
                                Write-Verbose " Wrote $($hvTagsForVM.Count) tags to VM Notes for $($vm.Name)"
                            }
                            catch {
                                $errMsg = "Failed to write tags to VM Notes for $($vm.Name): $_"
                                Write-Warning " $errMsg"
                                [void]$errors.Add([PSCustomObject]@{ VM = $vm.Name; Error = $errMsg })
                            }
                        }
                    }
                }
                else {
                    # --- VMware: use native vSphere tags ---
                    # Get current tag assignments for this VM (for comparison)
                    $currentAssignments = @()
                    if (-not $DryRun) {
                        try {
                            $currentAssignments = @(Get-TagAssignment -Entity $vm -ErrorAction SilentlyContinue)
                        }
                        catch {
                            Write-Verbose "Could not retrieve current tag assignments for $($vm.Name): $_"
                        }
                    }

                    # Process each category
                    foreach ($catDef in $tagProfile.Categories) {
                        $tagValues = @(Resolve-TagValue -VMMetadata $metadata -CategoryDefinition $catDef)

                        if ($tagValues.Count -eq 0) {
                            Write-Verbose " No tag resolved for $($catDef.Name) on $($vm.Name)"
                            continue
                        }

                        foreach ($tagValue in $tagValues) {
                            if ([string]::IsNullOrWhiteSpace($tagValue)) { continue }

                            if ($DryRun) {
                                Write-Host " [DRY RUN] $($vm.Name): $($catDef.Name) = $tagValue" -ForegroundColor Yellow
                                $tagAssignmentCount++
                                $vmTagResults[$catDef.Name] = $tagValue
                                continue
                            }

                            # Check if tag already assigned
                            $alreadyAssigned = $false
                            if (-not $Force) {
                                $existingForCategory = $currentAssignments | Where-Object {
                                    $_.Tag.Category.Name -eq $catDef.Name -and $_.Tag.Name -eq $tagValue
                                }
                                if ($existingForCategory) {
                                    $alreadyAssigned = $true
                                }
                            }

                            if ($alreadyAssigned) {
                                Write-Verbose " Tag '$($catDef.Name)/$tagValue' already assigned to $($vm.Name) -- skipping."
                                $tagSkippedCount++
                                $vmTagResults[$catDef.Name] = $tagValue
                                continue
                            }

                            # For Single cardinality, remove existing tags in this category first
                            if ($catDef.Cardinality -eq 'Single') {
                                $existingCatAssignments = @($currentAssignments | Where-Object {
                                    $_.Tag.Category.Name -eq $catDef.Name
                                })
                                foreach ($existing in $existingCatAssignments) {
                                    if ($PSCmdlet.ShouldProcess("$($vm.Name): Remove tag $($existing.Tag.Category.Name)/$($existing.Tag.Name)", "Remove Tag Assignment")) {
                                        try {
                                            Remove-TagAssignment -TagAssignment $existing -Confirm:$false -ErrorAction Stop
                                            Write-Verbose " Removed existing tag: $($existing.Tag.Category.Name)/$($existing.Tag.Name)"
                                        }
                                        catch {
                                            Write-Warning " Failed to remove existing tag from $($vm.Name): $_"
                                        }
                                    }
                                }
                            }

                            # Ensure the tag exists
                            $category = $categoryMap[$catDef.Name]
                            if (-not $category) {
                                Write-Warning " Category '$($catDef.Name)' not available -- skipping."
                                continue
                            }

                            $tag = Initialize-Tag -Name $tagValue -Category $category
                            if (-not $tag) {
                                Write-Warning " Could not create/find tag '$($catDef.Name)/$tagValue' -- skipping."
                                continue
                            }

                            # Assign the tag
                            if ($PSCmdlet.ShouldProcess("$($vm.Name): Assign tag $($catDef.Name)/$tagValue", "New Tag Assignment")) {
                                try {
                                    New-TagAssignment -Tag $tag -Entity $vm -ErrorAction Stop | Out-Null
                                    Write-Verbose " Assigned tag: $($catDef.Name)/$tagValue -> $($vm.Name)"
                                    $tagAssignmentCount++
                                    $vmTagResults[$catDef.Name] = $tagValue
                                }
                                catch {
                                    $errMsg = "Failed to assign tag '$($catDef.Name)/$tagValue' to $($vm.Name): $_"
                                    Write-Warning " $errMsg"
                                    [void]$errors.Add([PSCustomObject]@{ VM = $vm.Name; Error = $errMsg })
                                }
                            }
                        }
                    }
                }

                $vmProcessedCount++

                # Build result object for this VM
                [void]$syncResults.Add([PSCustomObject]@{
                    VMName     = $vm.Name
                    PowerState = $metadata.PowerState
                    Tags       = $vmTagResults
                    TagCount   = $vmTagResults.Count
                })

                # Build sync state entry
                [void]$syncState.Add([PSCustomObject]@{
                    Name               = $metadata.Name
                    PowerState         = $metadata.PowerState
                    GuestFamily        = $metadata.GuestFamily
                    GuestFullName      = $metadata.GuestFullName
                    NumCPU             = $metadata.NumCPU
                    MemoryGB           = $metadata.MemoryGB
                    ToolsStatus        = $metadata.ToolsStatus
                    SnapshotCount      = $metadata.SnapshotCount
                    SnapshotRisk       = $metadata.SnapshotRisk
                    Networks           = $metadata.Networks
                    Datastores         = $metadata.Datastores
                    ProvisionedSpaceGB = $metadata.ProvisionedSpaceGB
                    Cluster            = $metadata.Cluster
                    Datacenter         = $metadata.Datacenter
                    VMAge              = $metadata.VMAge
                })
            }

            Write-Progress -Activity "Syncing VM Tags" -Completed

            # --- Step 6: Save sync state ---
            if (-not $DryRun) {
                $stateFilePath = Join-Path -Path $script:StateDir -ChildPath 'last-sync.json'
                $stateData = [PSCustomObject]@{
                    SyncDate     = (Get-Date).ToString('o')
                    Server       = $Server
                    ProfileName  = $tagProfile.Name
                    VMCount      = $vmProcessedCount
                    VMs          = $syncState
                }

                try {
                    $stateData | ConvertTo-Json -Depth 10 | Set-Content -Path $stateFilePath -Encoding UTF8 -Force
                    Write-Verbose "Sync state saved to: $stateFilePath"
                }
                catch {
                    Write-Warning "Failed to save sync state: $_"
                }
            }

            # --- Step 7: Generate HTML report ---
            if ($OutputPath) {
                Write-Verbose "Generating HTML report: $OutputPath"
                $endTime = Get-Date
                $duration = $endTime - $startTime

                $htmlContent = Build-SyncReportHtml -Results $syncResults `
                    -Errors $errors `
                    -Server $Server `
                    -ProfileName $tagProfile.Name `
                    -StartTime $startTime `
                    -Duration $duration `
                    -TagAssignments $tagAssignmentCount `
                    -TagSkipped $tagSkippedCount `
                    -DryRun:$DryRun

                try {
                    $reportDir = Split-Path -Path $OutputPath -Parent
                    if ($reportDir -and -not (Test-Path $reportDir)) {
                        New-Item -Path $reportDir -ItemType Directory -Force | Out-Null
                    }
                    $htmlContent | Set-Content -Path $OutputPath -Encoding UTF8 -Force
                    Write-Host "Report saved to: $OutputPath" -ForegroundColor Green
                }
                catch {
                    Write-Warning "Failed to save HTML report: $_"
                }
            }
        }
        finally {
            # Disconnect from hypervisor
            if ($viConnection) {
                try {
                    Disconnect-Hypervisor -Server $Server -Hypervisor $Hypervisor
                    Write-Verbose "Disconnected from $Server."
                }
                catch {
                    Write-Verbose "Could not cleanly disconnect from $Server."
                }
            }
        }
    }

    end {
        $endTime = Get-Date
        $duration = $endTime - $startTime

        # Build summary object
        $summary = [PSCustomObject]@{
            Server            = $Server
            Profile           = if ($tagProfile) { $tagProfile.Name } else { 'default' }
            DryRun            = [bool]$DryRun
            VMsProcessed      = $vmProcessedCount
            TagsAssigned      = $tagAssignmentCount
            TagsSkipped       = $tagSkippedCount
            TagsCreated       = $tagCreatedCount
            CategoriesCreated = $categoryCreatedCount
            Errors            = $errors.Count
            Duration          = $duration
            StartTime         = $startTime
            EndTime           = $endTime
            Results           = $syncResults
        }

        $summary.PSObject.TypeNames.Insert(0, 'VMAutoTagger.SyncSummary')

        # Display summary
        Write-Host ""
        Write-Host "=== VM-AutoTagger Sync Summary ===" -ForegroundColor Cyan
        Write-Host " Server: $Server"
        Write-Host " Profile: $($summary.Profile)"
        Write-Host " VMs Processed: $vmProcessedCount"
        Write-Host " Tags Assigned: $tagAssignmentCount"
        Write-Host " Tags Skipped: $tagSkippedCount"
        Write-Host " Errors: $($errors.Count)"
        Write-Host " Duration: $($duration.ToString('mm\:ss'))"
        if ($DryRun) {
            Write-Host " Mode: DRY RUN (no changes made)" -ForegroundColor Yellow
        }
        Write-Host ""

        return $summary
    }
}

function Build-SyncReportHtml {
    [CmdletBinding()]
    param(
        [array]$Results,
        [array]$Errors,
        [string]$Server,
        [string]$ProfileName,
        [datetime]$StartTime,
        [timespan]$Duration,
        [int]$TagAssignments,
        [int]$TagSkipped,
        [switch]$DryRun
    )

    $sb = [System.Text.StringBuilder]::new()

    # Build error rows
    $errorSection = ''
    if ($Errors.Count -gt 0) {
        $errSb = [System.Text.StringBuilder]::new()
        foreach ($err in $Errors) {
            $enc = [System.Web.HttpUtility]::HtmlEncode($err.Error)
            [void]$errSb.AppendLine(('<tr><td>' + $err.VM + '</td><td class="text-warning">' + $enc + '</td></tr>'))
        }
        $errorSection = '<div class="card"><h2>Errors (' + $Errors.Count + ')</h2><table><tr><th>VM</th><th>Error</th></tr>' + $errSb.ToString() + '</table></div>'
    }

    # Build distribution cards
    $tagDistribution = @{}
    foreach ($result in $Results) {
        if ($result.Tags) {
            foreach ($key in $result.Tags.Keys) {
                if (-not $tagDistribution.ContainsKey($key)) { $tagDistribution[$key] = @{} }
                $val = $result.Tags[$key]
                if (-not $tagDistribution[$key].ContainsKey($val)) { $tagDistribution[$key][$val] = 0 }
                $tagDistribution[$key][$val]++
            }
        }
    }

    $distSb = [System.Text.StringBuilder]::new()
    foreach ($category in ($tagDistribution.Keys | Sort-Object)) {
        $tags = $tagDistribution[$category]
        $total = ($tags.Values | Measure-Object -Sum).Sum
        [void]$distSb.Append(('<div class="card"><h3>' + $category + '</h3><div class="bar-chart">'))
        foreach ($entry in ($tags.GetEnumerator() | Sort-Object Value -Descending)) {
            $pct = [math]::Round(($entry.Value / $total) * 100, 1)
            [void]$distSb.Append(('<div class="bar-row"><span class="bar-label">' + $entry.Key + '</span><div class="bar-track"><div class="bar-fill" style="width: ' + $pct + '%"></div></div><span class="bar-value">' + $entry.Value + ' (' + $pct + '%)</span></div>'))
        }
        [void]$distSb.AppendLine('</div></div>')
    }

    # Build VM rows
    $vmSb = [System.Text.StringBuilder]::new()
    foreach ($r in $Results) {
        $tagList = '<span class="text-muted">None</span>'
        if ($r.Tags -and $r.Tags.Count -gt 0) {
            $tSb = [System.Text.StringBuilder]::new()
            foreach ($t in $r.Tags.GetEnumerator()) {
                [void]$tSb.Append(('<span class="tag">' + $t.Key + ': ' + $t.Value + '</span> '))
            }
            $tagList = $tSb.ToString()
        }
        $stateClass = switch ($r.PowerState) { 'PoweredOn' { 'status-good' }; 'PoweredOff' { 'status-bad' }; default { 'status-warn' } }
        [void]$vmSb.AppendLine(('<tr><td>' + $r.VMName + '</td><td><span class="' + $stateClass + '">' + $r.PowerState + '</span></td><td>' + $r.TagCount + '</td><td>' + $tagList + '</td></tr>'))
    }

    $dryRunBanner = ''
    if ($DryRun) { $dryRunBanner = '<div class="banner warning">DRY RUN - No changes were made to vCenter</div>' }
    $tsStr = '{0:yyyy-MM-dd HH:mm:ss}' -f $StartTime
    $nowStr = '{0:yyyy-MM-dd HH:mm:ss}' -f (Get-Date)
    $durStr = $Duration.ToString('mm\:ss')

    $html = @'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VM-AutoTagger Sync Report</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;background:#0d1117;color:#c9d1d9;padding:2rem;line-height:1.6}
.header{background:linear-gradient(135deg,#1a1f2e 0%,#2a1a0a 100%);padding:2rem;border-radius:8px;margin-bottom:2rem;border:1px solid #30363d}
.header h1{color:#d29922;font-size:1.8rem;margin-bottom:.5rem}
.header .subtitle{color:#8b949e;font-size:.95rem}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
.stat-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.2rem;text-align:center}
.stat-card .value{font-size:2rem;font-weight:bold;color:#d29922}
.stat-card .label{color:#8b949e;font-size:.85rem;text-transform:uppercase;letter-spacing:.05em}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}
.card h2,.card h3{color:#d29922;margin-bottom:1rem}
table{width:100%;border-collapse:collapse;margin-top:.5rem}
th{background:#21262d;color:#d29922;padding:.75rem 1rem;text-align:left;font-weight:600;border-bottom:2px solid #30363d}
td{padding:.6rem 1rem;border-bottom:1px solid #21262d}
tr:hover{background:#1c2128}
.tag{display:inline-block;background:#1c2128;border:1px solid #30363d;border-radius:4px;padding:2px 8px;margin:2px;font-size:.8rem}
.banner{padding:1rem;border-radius:8px;margin-bottom:1.5rem;font-weight:600;text-align:center}
.banner.warning{background:#2a1a0a;border:1px solid #d29922;color:#d29922}
.status-good{color:#3fb950}.status-bad{color:#f85149}.status-warn{color:#d29922}
.text-warning{color:#d29922}.text-muted{color:#484f58}
.bar-chart{padding:.5rem 0}
.bar-row{display:flex;align-items:center;margin-bottom:.5rem}
.bar-label{width:160px;font-size:.85rem;color:#c9d1d9;flex-shrink:0}
.bar-track{flex:1;height:20px;background:#21262d;border-radius:4px;overflow:hidden;margin:0 .75rem}
.bar-fill{height:100%;background:linear-gradient(90deg,#d29922,#e8b84a);border-radius:4px;min-width:2px}
.bar-value{width:90px;font-size:.8rem;color:#8b949e;text-align:right;flex-shrink:0}
.footer{text-align:center;color:#484f58;font-size:.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid #21262d}
</style>
</head>
<body>
<div class="header"><h1>VM-AutoTagger Sync Report</h1><div class="subtitle">Server: %%SERVER%% | Profile: %%PROFILE%% | %%TIMESTAMP%%</div></div>
%%DRYBANNER%%
<div class="stats-grid">
<div class="stat-card"><div class="value">%%VMCOUNT%%</div><div class="label">VMs Processed</div></div>
<div class="stat-card"><div class="value">%%TAGASSIGN%%</div><div class="label">Tags Assigned</div></div>
<div class="stat-card"><div class="value">%%TAGSKIP%%</div><div class="label">Tags Skipped</div></div>
<div class="stat-card"><div class="value">%%ERRCOUNT%%</div><div class="label">Errors</div></div>
<div class="stat-card"><div class="value">%%DURATION%%</div><div class="label">Duration</div></div>
</div>
<div class="card"><h2>Tag Distribution</h2></div>
%%DISTCARDS%%
%%ERRSECTION%%
<div class="card"><h2>VM Details</h2>
<table><tr><th>VM Name</th><th>Power State</th><th>Tags</th><th>Tag Assignments</th></tr>
%%VMROWS%%
</table></div>
<div class="footer">Generated by VM-AutoTagger v1.0.0 | %%NOW%%</div>
</body></html>
'@


    $html = $html.Replace('%%SERVER%%', $Server)
    $html = $html.Replace('%%PROFILE%%', $ProfileName)
    $html = $html.Replace('%%TIMESTAMP%%', $tsStr)
    $html = $html.Replace('%%DRYBANNER%%', $dryRunBanner)
    $html = $html.Replace('%%VMCOUNT%%', $Results.Count.ToString())
    $html = $html.Replace('%%TAGASSIGN%%', $TagAssignments.ToString())
    $html = $html.Replace('%%TAGSKIP%%', $TagSkipped.ToString())
    $html = $html.Replace('%%ERRCOUNT%%', $Errors.Count.ToString())
    $html = $html.Replace('%%DURATION%%', $durStr)
    $html = $html.Replace('%%DISTCARDS%%', $distSb.ToString())
    $html = $html.Replace('%%ERRSECTION%%', $errorSection)
    $html = $html.Replace('%%VMROWS%%', $vmSb.ToString())
    $html = $html.Replace('%%NOW%%', $nowStr)

    return $html
}