#Requires -Version 5.1
<#
Windows 远程桌面机器初始化 —— 管理员账号登录后执行的部分。
用法：以管理员身份打开 PowerShell，cd 到这个目录，运行：
  .\admin-init.ps1 -RdpPort 33950

10 个步骤（改密码 / 激活状态检查 / 建 playz 账号 / RDP 防火墙白名单 /
开 WinRM / RDP 传输优化 / RDP 会话时间限制 / 桌面视觉效果 / 系统时钟
同步间隔+锁定时区 / 安装 windows_exporter）。

RDP/WinRM 白名单默认从 Galaxy 的运维通用白名单自动拿（config\settings.ps1 的
GalaxyApiBaseUrl），-WhitelistIPs/-WinRmWhitelistIPs 是在这基础上追加的手动地址，
不是必填。传 -Hostname（跟 Galaxy 里登记这台机器时用的主机名一致）的话，RDP 白名单
还会自动带上这台机器在 Galaxy 里的专属白名单，不用再把同样的地址在命令行重复敲一遍：
  .\admin-init.ps1 -RdpPort 33950 -Hostname ajun-hk-01 -WhitelistIPs 1.2.3.4 -WinRmWhitelistIPs 9.9.9.9

可以用开关跳过某一步，方便断点续跑：
  .\admin-init.ps1 -SkipPasswordChange -SkipFirewall -SkipWinRM -SkipRdpTuning -SkipSessionTimeLimits -SkipDesktopPerfTweaks -SkipClockSync -SkipWindowsExporter
#>

param(
    [int]$RdpPort = 33950,
    [string[]]$WhitelistIPs = @(),
    [string]$Hostname = '',
    [int]$WinRmPort = 25986,
    [string[]]$WinRmWhitelistIPs = @(),
    [switch]$SkipPasswordChange,
    [switch]$SkipActivationCheck,
    [switch]$SkipPlayzUser,
    [switch]$SkipFirewall,
    [switch]$SkipWinRM,
    [switch]$SkipRdpTuning,
    [switch]$SkipSessionTimeLimits,
    [switch]$SkipDesktopPerfTweaks,
    [switch]$SkipClockSync,
    [switch]$SkipWindowsExporter
)

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $ScriptDir 'lib\Functions.ps1')
. (Join-Path $ScriptDir 'config\settings.ps1')

Assert-InitIsAdmin

# ---------- 步骤 1：交互式修改管理员密码 ----------
function Set-InitAdminPassword {
    if (-not (Test-InitLocalUserExists -Name $AdminUsername)) {
        Write-InitError "Local user '$AdminUsername' does not exist, check `$AdminUsername in config\settings.ps1"
        exit 1
    }

    Write-InitLog "Changing password for local user '$AdminUsername' (interactive input required)"
    $pw1 = Read-Host -AsSecureString "Enter new password for $AdminUsername"
    $pw2 = Read-Host -AsSecureString 'Confirm new password'

    if (-not (Test-InitSecureStringEqual -First $pw1 -Second $pw2)) {
        Write-InitError 'Passwords did not match, aborting'
        exit 1
    }
    if ($pw1.Length -eq 0) {
        Write-InitError 'Password cannot be empty, aborting'
        exit 1
    }

    $beforeSet = (Get-LocalUser -Name $AdminUsername).PasswordLastSet

    try {
        Set-LocalUser -Name $AdminUsername -Password $pw1 -ErrorAction Stop
    } catch {
        Write-InitError "Failed to set password for $AdminUsername`: $($_.Exception.Message)"
        exit 1
    }

    $afterSet = (Get-LocalUser -Name $AdminUsername).PasswordLastSet
    if (-not $afterSet -or ($beforeSet -and $afterSet -le $beforeSet)) {
        Write-InitError "Set-LocalUser did not report an error, but PasswordLastSet for $AdminUsername did not advance (before=$beforeSet after=$afterSet) - the password change may not have actually taken effect"
        exit 1
    }
    Write-InitLog "Password for '$AdminUsername' updated (PasswordLastSet: $afterSet)"
}

# ---------- 步骤 2：检查激活状态 ----------
# 三态返回：$true=已激活 $false=确认未激活 $null=查询失败，$null 必须当硬错误处理，
# 不能当"未激活"去关防护（详见 .claude/design.md）
function Test-InitWindowsActivated {
    try {
        $product = Get-CimInstance -ClassName SoftwareLicensingProduct -ErrorAction Stop |
            Where-Object { $_.PartialProductKey -and $_.Name -like 'Windows*' } |
            Select-Object -First 1
    } catch {
        Write-InitError "Failed to query Windows license status: $($_.Exception.Message)"
        return $null
    }

    if (-not $product) {
        Write-InitError 'Could not determine Windows license status (no matching SoftwareLicensingProduct entry found) - unexpected on a real Windows install'
        return $null
    }

    # LicenseStatus: 1=已激活，其余（含各种宽限期/通知状态）都算未激活
    return ($product.LicenseStatus -eq 1)
}

function Test-InitActivation {
    $activated = Test-InitWindowsActivated
    if ($null -eq $activated) {
        Write-InitError 'Could not determine activation status, refusing to continue'
        exit 1
    }

    if ($activated) {
        Write-InitLog 'Windows is activated'
    } else {
        Write-InitWarn 'Windows is NOT activated'
        Write-InitWarn 'Run the activation tool manually now, then re-run this script - it will detect activation and continue.'
        exit 0
    }
}

# ---------- 步骤 3：创建 playz 账号 ----------
function New-InitPlayzUser {
    if ($PlayzPassword -eq 'CHANGE_ME_BEFORE_RUNNING') {
        Write-InitError 'PlayzPassword in config\settings.ps1 is still the placeholder value - set a real password before running this step'
        exit 1
    }
    if ($PlayzUsername -ieq $AdminUsername) {
        Write-InitError 'PlayzUsername must not be the same as AdminUsername'
        exit 1
    }

    $securePw = ConvertTo-SecureString $PlayzPassword -AsPlainText -Force

    $beforeSet = $null
    if (Test-InitLocalUserExists -Name $PlayzUsername) {
        $beforeSet = (Get-LocalUser -Name $PlayzUsername).PasswordLastSet
    }

    if (-not (Test-InitLocalUserExists -Name $PlayzUsername)) {
        Write-InitLog "Creating local user '$PlayzUsername'"
        New-LocalUser -Name $PlayzUsername -Password $securePw `
            -PasswordNeverExpires:$true -UserMayNotChangePassword:$true `
            -Description 'Managed by init-win, pw fixed in settings.ps1' `
            -ErrorAction Stop | Out-Null
    } else {
        # Set-LocalUser 没有 -UserMayNotChangePassword，对应参数是 -UserMayChangePassword
        # （极性相反），详见 .claude/design.md
        Write-InitLog "User '$PlayzUsername' already exists, syncing password/policy to match settings.ps1"
        Set-LocalUser -Name $PlayzUsername -Password $securePw `
            -PasswordNeverExpires:$true -UserMayChangePassword:$false `
            -ErrorAction Stop
    }

    $after = Get-LocalUser -Name $PlayzUsername
    if (-not $after.PasswordLastSet -or ($beforeSet -and $after.PasswordLastSet -le $beforeSet)) {
        Write-InitError "Password for $PlayzUsername does not appear to have been updated (PasswordLastSet before=$beforeSet after=$($after.PasswordLastSet))"
        exit 1
    }
    # 不直接读 $after.PasswordNeverExpires，用带 ADSI fallback 的 helper，见 Functions.ps1
    if (-not (Test-InitPasswordNeverExpires -UserName $PlayzUsername)) {
        Write-InitError "PasswordNeverExpires for $PlayzUsername is not set (checked via Get-LocalUser and ADSI UserFlags fallback)"
        exit 1
    }
    if ($after.UserMayChangePassword -ne $false) {
        Write-InitError "UserMayChangePassword for $PlayzUsername reads as '$($after.UserMayChangePassword)', expected False"
        exit 1
    }

    if (-not (Test-InitLocalGroupMember -GroupName 'Administrators' -UserName $PlayzUsername)) {
        Write-InitLog "Adding '$PlayzUsername' to Administrators"
        Add-LocalGroupMember -Group 'Administrators' -Member $PlayzUsername -ErrorAction Stop
        if (-not (Test-InitLocalGroupMember -GroupName 'Administrators' -UserName $PlayzUsername)) {
            Write-InitError "Added '$PlayzUsername' to Administrators but membership check still does not confirm it"
            exit 1
        }
    } else {
        Write-InitLog "'$PlayzUsername' is already in Administrators"
    }

    Write-InitLog "User '$PlayzUsername' ready (Administrators member, fixed password, cannot self-change password)"
}

# ---------- 步骤 4：防火墙远程登录端口白名单 ----------
function Set-InitFirewallRule {
    param(
        [Parameter(Mandatory)][int]$RdpPort,
        [string[]]$WhitelistIPs = @()
    )

    $ruleName = 'RDP Allow Whitelist'
    $ipPattern = '^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$'

    foreach ($ip in $WhitelistIPs) {
        if ($ip -notmatch $ipPattern) {
            Write-InitError "'$ip' does not look like a valid IPv4/CIDR address, aborting before touching the firewall"
            exit 1
        }
    }

    $existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
    $existingIPs = @()
    if ($existingRule) {
        $existingIPs = @(Get-NetFirewallAddressFilter -AssociatedNetFirewallRule $existingRule |
            Select-Object -ExpandProperty RemoteAddress) | Where-Object { $_ -and $_ -ne 'Any' }
    }

    $mergedIPs = @($existingIPs + $WhitelistIPs | Sort-Object -Unique)

    if ($mergedIPs.Count -eq 0) {
        Write-InitError 'Resulting whitelist would be empty - refusing to lock down RDP with no allowed address (would lock everyone out). Pass -WhitelistIPs with at least one address.'
        exit 1
    }

    if ($existingRule) {
        Write-InitLog "Updating '$ruleName': port=$RdpPort, whitelist=$($mergedIPs -join ', ')"
        Set-NetFirewallRule -DisplayName $ruleName -Enabled True -Action Allow -Direction Inbound -Protocol TCP -LocalPort $RdpPort
        Get-NetFirewallRule -DisplayName $ruleName | Set-NetFirewallAddressFilter -RemoteAddress $mergedIPs
    } else {
        Write-InitLog "Creating '$ruleName': port=$RdpPort, whitelist=$($mergedIPs -join ', ')"
        New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort $RdpPort `
            -RemoteAddress $mergedIPs -Action Allow | Out-Null
    }

    # 用语言无关的内置规则组标识符匹配，中英文 Windows 都能命中同一组规则
    $builtinRdpGroup = '@FirewallAPI.dll,-28752'
    $builtinRules = Get-NetFirewallRule -Group $builtinRdpGroup -ErrorAction SilentlyContinue
    foreach ($r in $builtinRules) {
        if ($r.Enabled -eq 'True') {
            Write-InitLog "Disabling built-in rule: $($r.DisplayName)"
            Disable-NetFirewallRule -Name $r.Name
        }
    }

    if (Get-NetFirewallRule -DisplayName 'RDP-Allow-Rule' -ErrorAction SilentlyContinue) {
        Write-InitLog "Disabling legacy rule: RDP-Allow-Rule"
        Disable-NetFirewallRule -DisplayName 'RDP-Allow-Rule'
    }

    Set-NetFirewallProfile -Profile Domain, Public, Private -Enabled True -DefaultInboundAction Block

    Write-InitLog '--- Firewall verification ---'
    Get-NetFirewallRule -DisplayName $ruleName | Get-NetFirewallAddressFilter | ForEach-Object {
        Write-InitLog "Whitelist rule remote addresses: $($_.RemoteAddress -join ', ')"
    }
    Get-NetFirewallRule | Where-Object {
        $_.DisplayName -like '*Remote Desktop*' -or $_.DisplayName -like '*远程桌面*' -or $_.DisplayName -like '*RDP*'
    } | ForEach-Object {
        Write-InitLog "Rule '$($_.DisplayName)': Action=$($_.Action) Enabled=$($_.Enabled)"
    }
    Get-NetFirewallProfile | ForEach-Object {
        Write-InitLog "Profile '$($_.Name)': Enabled=$($_.Enabled) DefaultInboundAction=$($_.DefaultInboundAction)"
    }
}

# ---------- 步骤 5：开启 WinRM（供 Galaxy 远程下发命令用） ----------
function Get-InitWinRmCertificate {
    $existing = Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
        Where-Object { $_.DnsNameList.Unicode -contains $env:COMPUTERNAME -and $_.NotAfter -gt (Get-Date).AddYears(1) } |
        Sort-Object NotAfter -Descending | Select-Object -First 1

    if ($existing) {
        Write-InitLog "Reusing existing WinRM certificate (thumbprint $($existing.Thumbprint), expires $($existing.NotAfter))"
        return $existing
    }

    Write-InitLog 'Creating self-signed certificate for WinRM HTTPS listener (10-year validity)'
    return New-SelfSignedCertificate -DnsName $env:COMPUTERNAME `
        -CertStoreLocation Cert:\LocalMachine\My -NotAfter (Get-Date).AddYears(10)
}

function Set-InitWinRmListener {
    param(
        [Parameter(Mandatory)][int]$WinRmPort,
        [Parameter(Mandatory)]$Certificate
    )

    $existingHttps = Get-ChildItem WSMan:\localhost\Listener -ErrorAction SilentlyContinue |
        Where-Object { ($_ | Get-ChildItem | Where-Object { $_.Name -eq 'Transport' }).Value -eq 'HTTPS' }

    foreach ($listener in $existingHttps) {
        $port = ($listener | Get-ChildItem | Where-Object { $_.Name -eq 'Port' }).Value
        $thumb = ($listener | Get-ChildItem | Where-Object { $_.Name -eq 'CertificateThumbprint' }).Value
        if ($port -eq "$WinRmPort" -and $thumb -eq $Certificate.Thumbprint) {
            Write-InitLog "HTTPS listener already exists on port $WinRmPort with the expected certificate"
            return
        }
        Write-InitLog "Removing stale HTTPS listener (port=$port, thumbprint=$thumb)"
        $listener | Remove-Item -Recurse -Force
    }

    Write-InitLog "Creating HTTPS listener on port $WinRmPort"
    New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -Port $WinRmPort `
        -CertificateThumbPrint $Certificate.Thumbprint -Force | Out-Null
}

# 用 WSMan provider 删，不用 winrm.cmd——实测会被部分机器的安全软件/本地策略拦成
# "拒绝访问"，详见 .claude/design.md。删不掉不阻塞，只警告
function Remove-InitWinRmHttpListener {
    $httpListeners = Get-ChildItem WSMan:\localhost\Listener -ErrorAction SilentlyContinue |
        Where-Object { ($_ | Get-ChildItem | Where-Object { $_.Name -eq 'Transport' }).Value -eq 'HTTP' }

    if (-not $httpListeners) {
        Write-InitLog 'No default HTTP listener present, nothing to remove'
        return
    }

    try {
        $httpListeners | Remove-Item -Recurse -Force -ErrorAction Stop
        Write-InitLog 'Removed default HTTP listener'
    } catch {
        Write-InitWarn "Failed to remove default HTTP listener: $($_.Exception.Message) - not fatal, port 5985 stays firewalled off by default-deny policy"
    }
}

function Set-InitWinRmFirewallRule {
    param(
        [Parameter(Mandatory)][int]$WinRmPort,
        [string[]]$WinRmWhitelistIPs = @()
    )

    $ruleName = 'WinRM HTTPS Allow Whitelist'
    $ipPattern = '^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$'

    foreach ($ip in $WinRmWhitelistIPs) {
        if ($ip -notmatch $ipPattern) {
            Write-InitError "'$ip' does not look like a valid IPv4/CIDR address, aborting before touching the WinRM firewall rule"
            exit 1
        }
    }

    $existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
    $existingIPs = @()
    if ($existingRule) {
        $existingIPs = @(Get-NetFirewallAddressFilter -AssociatedNetFirewallRule $existingRule |
            Select-Object -ExpandProperty RemoteAddress) | Where-Object { $_ -and $_ -ne 'Any' }
    }

    $mergedIPs = @($existingIPs + $WinRmWhitelistIPs | Sort-Object -Unique)

    if ($mergedIPs.Count -eq 0) {
        Write-InitError 'Resulting WinRM whitelist would be empty - refusing to open WinRM with no allowed address (would open remote command execution to nobody or, worse, leave it unrestricted). Pass -WinRmWhitelistIPs with at least the Galaxy server outbound IP(s).'
        exit 1
    }

    if ($existingRule) {
        Write-InitLog "Updating '$ruleName': port=$WinRmPort, whitelist=$($mergedIPs -join ', ')"
        Set-NetFirewallRule -DisplayName $ruleName -Enabled True -Action Allow -Direction Inbound -Protocol TCP -LocalPort $WinRmPort
        Get-NetFirewallRule -DisplayName $ruleName | Set-NetFirewallAddressFilter -RemoteAddress $mergedIPs
    } else {
        Write-InitLog "Creating '$ruleName': port=$WinRmPort, whitelist=$($mergedIPs -join ', ')"
        New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort $WinRmPort `
            -RemoteAddress $mergedIPs -Action Allow | Out-Null
    }

    # 按端口号匹配，不按 DisplayName——'Windows Remote Management (HTTP-In)' 是英文名，
    # 中文 Windows 上实测找不到（真实名字是本地化过的"Windows 远程管理(HTTP-In)"），
    # 详见 .claude/design.md
    $defaultRules = Get-NetFirewallRule -Direction Inbound -Action Allow -ErrorAction SilentlyContinue | Where-Object {
        Get-NetFirewallPortFilter -AssociatedNetFirewallRule $_ | Where-Object { $_.LocalPort -eq 5985 }
    }
    foreach ($r in $defaultRules) {
        if ($r.Enabled -eq 'True') {
            Write-InitLog "Disabling default rule: $($r.DisplayName) ($($r.Name))"
            Disable-NetFirewallRule -Name $r.Name
        }
    }
}

function Enable-InitWinRmUacRemoteRestriction {
    $path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
    $name = 'LocalAccountTokenFilterPolicy'

    try {
        New-ItemProperty -Path $path -Name $name -Value 1 -PropertyType DWord -Force -ErrorAction Stop | Out-Null
    } catch {
        Write-InitError "Failed to set $name`: $($_.Exception.Message)"
        exit 1
    }

    $actual = (Get-ItemProperty -Path $path -Name $name -ErrorAction SilentlyContinue).$name
    if ($actual -ne 1) {
        Write-InitError "$name does not read back as 1 (got '$actual') after setting it"
        exit 1
    }
    Write-InitLog "$name set to 1 (UAC remote token filtering disabled)"
}

function Set-InitWinRM {
    param(
        [Parameter(Mandatory)][int]$WinRmPort,
        [string[]]$WinRmWhitelistIPs = @()
    )

    Write-InitLog 'Enabling PS Remoting'
    Enable-PSRemoting -Force -SkipNetworkProfileCheck -ErrorAction Stop

    $cert = Get-InitWinRmCertificate
    Set-InitWinRmListener -WinRmPort $WinRmPort -Certificate $cert
    Remove-InitWinRmHttpListener
    Set-InitWinRmFirewallRule -WinRmPort $WinRmPort -WinRmWhitelistIPs $WinRmWhitelistIPs
    Enable-InitWinRmUacRemoteRestriction

    Write-InitLog '--- WinRM verification ---'
    Get-ChildItem WSMan:\localhost\Listener | ForEach-Object {
        $t = ($_ | Get-ChildItem | Where-Object { $_.Name -eq 'Transport' }).Value
        $p = ($_ | Get-ChildItem | Where-Object { $_.Name -eq 'Port' }).Value
        Write-InitLog "Listener: Transport=$t Port=$p"
    }
    Get-NetFirewallRule -DisplayName 'WinRM HTTPS Allow Whitelist' | Get-NetFirewallAddressFilter | ForEach-Object {
        Write-InitLog "WinRM whitelist rule remote addresses: $($_.RemoteAddress -join ', ')"
    }
    if (Test-InitLocalGroupMember -GroupName 'Administrators' -UserName $AdminUsername) {
        Write-InitLog "'$AdminUsername' confirmed in Administrators (used for WinRM NTLM auth)"
    } else {
        Write-InitWarn "'$AdminUsername' is NOT in Administrators - WinRM NTLM auth using this account will fail"
    }
}

# ---------- 步骤 6：RDP 传输优化（强制 TCP + 保活） ----------
# 直接写注册表 Policies 位置，不经过 gpedit.msc/registry.pol，界面显示"未配置"是
# 预期现象，不代表没生效，详见 .claude/design.md
function Set-InitRdpTransportTuning {
    $tsPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'
    $tsControlPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server'
    $tcpipPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters'

    if (-not (Test-Path $tsPolicyPath)) {
        New-Item -Path $tsPolicyPath -Force | Out-Null
    }

    $settings = @(
        @{ Path = $tsPolicyPath;  Name = 'fDisableUdp';       Value = 1 }
        @{ Path = $tsControlPath; Name = 'KeepAliveEnable';   Value = 1 }
        @{ Path = $tsControlPath; Name = 'KeepAliveInterval'; Value = 1 }
        # TCP 层 keepalive 是系统全局设置，会影响这台机器上所有 TCP 连接，不只是 RDP
        @{ Path = $tcpipPath;     Name = 'KeepAliveTime';     Value = 300000 }
        @{ Path = $tcpipPath;     Name = 'KeepAliveInterval'; Value = 30000 }
    )

    foreach ($s in $settings) {
        try {
            Set-ItemProperty -Path $s.Path -Name $s.Name -Value $s.Value -Type DWord -Force -ErrorAction Stop
        } catch {
            Write-InitError "Failed to set $($s.Name) at $($s.Path): $($_.Exception.Message)"
            exit 1
        }

        $actual = (Get-ItemProperty -Path $s.Path -Name $s.Name -ErrorAction SilentlyContinue).$($s.Name)
        if ($actual -ne $s.Value) {
            Write-InitError "$($s.Name) at $($s.Path) does not read back as $($s.Value) (got '$actual') after setting it"
            exit 1
        }
        Write-InitLog "$($s.Name) set to $($s.Value) at $($s.Path)"
    }

    Write-InitLog 'RDP transport tuning applied (UDP disabled, keepalive enabled) - takes effect on new RDP connections; gpedit.msc UI showing "Not Configured" is expected since this writes the registry directly rather than through registry.pol'
}

# ---------- 步骤 7：RDP 会话时间限制 ----------
# fResetBroken 打开后，到达时间限制是结束会话（相当于注销），不是仅断开保留状态，
# 未保存的工作会丢失
function Set-InitRdpSessionTimeLimits {
    $tsPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'

    if (-not (Test-Path $tsPolicyPath)) {
        New-Item -Path $tsPolicyPath -Force | Out-Null
    }

    $settings = @(
        @{ Name = 'MaxDisconnectionTime'; Value = 900000 }    # 15 分钟：已中断会话
        @{ Name = 'MaxIdleTime';          Value = 1800000 }   # 30 分钟：空闲会话
        @{ Name = 'MaxConnectionTime';    Value = 43200000 }  # 12 小时：活动会话
        @{ Name = 'fResetBroken';         Value = 1 }         # 超限结束会话而非仅断开
    )

    foreach ($s in $settings) {
        try {
            Set-ItemProperty -Path $tsPolicyPath -Name $s.Name -Value $s.Value -Type DWord -Force -ErrorAction Stop
        } catch {
            Write-InitError "Failed to set $($s.Name): $($_.Exception.Message)"
            exit 1
        }

        $actual = (Get-ItemProperty -Path $tsPolicyPath -Name $s.Name -ErrorAction SilentlyContinue).$($s.Name)
        if ($actual -ne $s.Value) {
            Write-InitError "$($s.Name) does not read back as $($s.Value) (got '$actual') after setting it"
            exit 1
        }
        Write-InitLog "$($s.Name) set to $($s.Value)"
    }

    Write-InitLog 'RDP session time limits applied (disconnected=15min, idle=30min, active=12h, terminate-on-limit=on) - takes effect on new sessions; gpedit.msc UI showing "Not Configured" is expected, same as step 6'
}

# ---------- 步骤 8：视觉效果"最佳性能" + 桌面壁纸纯色 ----------
# 每用户设置（HKCU），应用到当前会话 + Default 用户模板（让 playz 和以后新建账号
# 首次登录就是这个配置）。已登录过的其它账号不处理，需要手动加载对应 NTUSER.DAT
function Set-InitPerUserPerfTweaks {
    param(
        [Parameter(Mandatory)][string]$HiveRoot,
        [switch]$LiveSession
    )

    $desktopPath = "$HiveRoot\Control Panel\Desktop"
    $windowMetricsPath = "$HiveRoot\Control Panel\Desktop\WindowMetrics"
    $colorsPath = "$HiveRoot\Control Panel\Colors"
    $vfxPath = "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects"
    $advancedPath = "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced"

    foreach ($p in @($windowMetricsPath, $colorsPath, $vfxPath, $advancedPath)) {
        if (-not (Test-Path $p)) { New-Item -Path $p -Force | Out-Null }
    }

    # "调整为最佳性能"对话框实际读写的二进制位图，系统自己写入的标准值
    $prefMask = [byte[]](0x90, 0x12, 0x03, 0x80, 0x10, 0x00, 0x00, 0x00)
    Set-ItemProperty -Path $desktopPath -Name 'UserPreferencesMask' -Value $prefMask -Type Binary -Force
    Set-ItemProperty -Path $desktopPath -Name 'DragFullWindows' -Value '0' -Type String -Force
    Set-ItemProperty -Path $windowMetricsPath -Name 'MinAnimate' -Value '0' -Type String -Force
    Set-ItemProperty -Path $vfxPath -Name 'VisualFXSetting' -Value 2 -Type DWord -Force
    Set-ItemProperty -Path $advancedPath -Name 'TaskbarAnimations' -Value 0 -Type DWord -Force
    Set-ItemProperty -Path $advancedPath -Name 'ListviewAlphaSelect' -Value 0 -Type DWord -Force
    Set-ItemProperty -Path $advancedPath -Name 'ListviewShadow' -Value 0 -Type DWord -Force

    # 纯色壁纸：Wallpaper 留空 + Background 写 RGB，不涉及图片文件
    Set-ItemProperty -Path $desktopPath -Name 'Wallpaper' -Value '' -Type String -Force
    Set-ItemProperty -Path $colorsPath -Name 'Background' -Value '0 0 0' -Type String -Force

    $actualMask = (Get-ItemProperty -Path $desktopPath -Name 'UserPreferencesMask' -ErrorAction SilentlyContinue).UserPreferencesMask
    if (-not $actualMask -or (Compare-Object $actualMask $prefMask)) {
        Write-InitError "UserPreferencesMask at $desktopPath did not read back as expected"
        exit 1
    }
    $checks = @(
        @{ Path = $vfxPath;     Name = 'VisualFXSetting'; Expected = 2 }
        @{ Path = $colorsPath;  Name = 'Background';       Expected = '0 0 0' }
        @{ Path = $desktopPath; Name = 'Wallpaper';        Expected = '' }
    )
    foreach ($c in $checks) {
        $actual = (Get-ItemProperty -Path $c.Path -Name $c.Name -ErrorAction SilentlyContinue).$($c.Name)
        if ($actual -ne $c.Expected) {
            Write-InitError "$($c.Name) at $($c.Path) does not read back as '$($c.Expected)' (got '$actual')"
            exit 1
        }
    }

    Write-InitLog "Per-user perf tweaks (best-performance visual effects + solid black wallpaper) written to $HiveRoot"

    if ($LiveSession) {
        try {
            & rundll32.exe user32.dll,UpdatePerUserSystemParameters
            Write-InitLog 'Wallpaper refreshed for current session (visual effects need a re-logon or Explorer restart to show in the UI)'
        } catch {
            Write-InitWarn "Failed to refresh wallpaper for current session: $($_.Exception.Message) - will still apply after next logon"
        }
    }
}

# reg.exe load/unload 挂载 Default 用户模板 hive（PS5.1 注册表 provider 不支持挂载任意
# hive 文件）。unload 前必须强制 GC，否则会因为句柄未释放而报错，详见 .claude/design.md
function Set-InitDefaultProfilePerfTweaks {
    $defaultHivePath = 'C:\Users\Default\NTUSER.DAT'
    if (-not (Test-Path $defaultHivePath)) {
        Write-InitError "$defaultHivePath not found, cannot tune the default user profile"
        exit 1
    }

    $hiveKey = 'InitDefaultUserTemp'
    $loadResult = & reg.exe load "HKU\$hiveKey" $defaultHivePath 2>&1
    if ($LASTEXITCODE -ne 0) {
        Write-InitError "Failed to load $defaultHivePath : $loadResult"
        exit 1
    }

    try {
        Set-InitPerUserPerfTweaks -HiveRoot "Registry::HKEY_USERS\$hiveKey"
    } finally {
        [gc]::Collect()
        [gc]::WaitForPendingFinalizers()
        $unloadResult = & reg.exe unload "HKU\$hiveKey" 2>&1
        if ($LASTEXITCODE -ne 0) {
            Write-InitError "Failed to unload HKU\$hiveKey : $unloadResult - the default profile hive may still be locked, check manually (reg unload HKU\$hiveKey)"
            exit 1
        }
    }

    Write-InitLog 'Default user profile tuned - new local accounts (including playz, on first logon) will inherit best-performance visual effects + solid black wallpaper'
}

function Set-InitDesktopPerfAndWallpaper {
    Set-InitPerUserPerfTweaks -HiveRoot 'HKCU:' -LiveSession
    Set-InitDefaultProfilePerfTweaks
}

# ---------- 步骤 9：系统时钟同步间隔缩短 + 锁定时区为北京时间（UTC+08:00） ----------
function Set-InitClockSyncInterval {
    param(
        [int]$IntervalSeconds = 180
    )

    $configPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Config'
    $ntpClientPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\TimeProviders\NtpClient'
    $minPollExponent = 7

    if (-not (Test-Path $configPath) -or -not (Test-Path $ntpClientPath)) {
        Write-InitError "W32Time Config/NtpClient registry keys not found - service may not be present on this machine"
        exit 1
    }

    try {
        Set-ItemProperty -Path $configPath -Name 'MinPollInterval' -Value $minPollExponent -Type DWord -Force -ErrorAction Stop
        Set-ItemProperty -Path $ntpClientPath -Name 'SpecialPollInterval' -Value $IntervalSeconds -Type DWord -Force -ErrorAction Stop
    } catch {
        Write-InitError "Failed to set MinPollInterval/SpecialPollInterval: $($_.Exception.Message)"
        exit 1
    }

    try {
        Set-Service -Name w32time -StartupType Automatic -ErrorAction Stop
        Restart-Service -Name w32time -Force -ErrorAction Stop
        w32tm /config /update | Out-Null
        w32tm /resync /force | Out-Null
    } catch {
        Write-InitError "Failed to restart w32time service: $($_.Exception.Message)"
        exit 1
    }

    $actualMinPoll = (Get-ItemProperty -Path $configPath -Name 'MinPollInterval' -ErrorAction SilentlyContinue).MinPollInterval
    if ($actualMinPoll -ne $minPollExponent) {
        Write-InitError "MinPollInterval at $configPath does not read back as $minPollExponent (got '$actualMinPoll') after setting it"
        exit 1
    }
    $actualSpecial = (Get-ItemProperty -Path $ntpClientPath -Name 'SpecialPollInterval' -ErrorAction SilentlyContinue).SpecialPollInterval
    if ($actualSpecial -ne $IntervalSeconds) {
        Write-InitError "SpecialPollInterval at $ntpClientPath does not read back as $IntervalSeconds (got '$actualSpecial') after setting it"
        exit 1
    }

    $svc = Get-Service -Name w32time
    if ($svc.Status -ne 'Running' -or $svc.StartType -ne 'Automatic') {
        Write-InitError "w32time service is not Running/Automatic after configuration (Status=$($svc.Status), StartType=$($svc.StartType))"
        exit 1
    }

    Write-InitLog "Clock sync interval set: MinPollInterval=2^$minPollExponent ($([math]::Pow(2,$minPollExponent))s floor), SpecialPollInterval=$IntervalSeconds`s, w32time restarted (Automatic/Running) and force-resynced"

    # 锁时区跟锁时钟同步间隔是同一件事的两面（都是让这台机器的时间表现稳定可预期），
    # 放在同一步里一起做，不单开一个步骤/Skip 开关
    $targetTzId = 'China Standard Time'
    $tzSvc = Get-Service -Name tzautoupdate -ErrorAction SilentlyContinue
    if ($tzSvc -and $tzSvc.StartType -ne 'Disabled') {
        Write-InitLog "Disabling tzautoupdate service (was StartType=$($tzSvc.StartType))"
        if ($tzSvc.Status -ne 'Stopped') {
            Stop-Service -Name tzautoupdate -Force -ErrorAction Stop
        }
        Set-Service -Name tzautoupdate -StartupType Disabled -ErrorAction Stop
    } else {
        Write-InitLog 'tzautoupdate already Disabled (or not present on this machine)'
    }

    $currentTz = Get-TimeZone
    if ($currentTz.Id -ne $targetTzId) {
        Write-InitLog "Setting timezone to '$targetTzId' (was '$($currentTz.Id)')"
        Set-TimeZone -Id $targetTzId -ErrorAction Stop
    } else {
        Write-InitLog "Timezone already '$targetTzId'"
    }

    $tzSvcAfter = Get-Service -Name tzautoupdate -ErrorAction SilentlyContinue
    if ($tzSvcAfter -and $tzSvcAfter.StartType -ne 'Disabled') {
        Write-InitError "tzautoupdate StartType does not read back as Disabled (got '$($tzSvcAfter.StartType)') after setting it"
        exit 1
    }
    $tzAfter = Get-TimeZone
    if ($tzAfter.Id -ne $targetTzId) {
        Write-InitError "Timezone does not read back as '$targetTzId' (got '$($tzAfter.Id)') after setting it"
        exit 1
    }

    Write-InitLog "Timezone locked: Id=$($tzAfter.Id) BaseUtcOffset=$($tzAfter.BaseUtcOffset), automatic timezone update disabled"
}

# ---------- 步骤 10：安装 windows_exporter（给 Prometheus 采集用） ----------
# 只用 vendor\ 里校验过 SHA256 的本地安装包，不连网下载 GitHub Release——跟 login-totp 项目
# install.ps1 同样的思路，见 .claude/design.md。幂等：服务已存在就跳过 MSI 安装本身，但防火墙
# 规则和服务状态每次都会重新确认/纠正一遍（防止装好之后规则被人手动改掉或者服务被停用）。
function Install-InitWindowsExporter {
    $msiPath = Join-Path $ScriptDir "vendor\$WindowsExporterMsiName"
    if (-not (Test-Path $msiPath)) {
        Write-InitError "$msiPath not found - check `$WindowsExporterMsiName in config\settings.ps1 and that vendor\ has the file"
        exit 1
    }

    $actualHash = (Get-FileHash -Path $msiPath -Algorithm SHA256).Hash.ToLower()
    if ($actualHash -ne $WindowsExporterMsiSha256.ToLower()) {
        Write-InitError "SHA256 mismatch for $msiPath (expected $WindowsExporterMsiSha256, got $actualHash) - refusing to install a file that doesn't match the known-good hash"
        exit 1
    }
    Write-InitLog "vendor MSI SHA256 verified ($WindowsExporterMsiName)"

    $existingSvc = Get-Service -Name windows_exporter -ErrorAction SilentlyContinue
    if ($existingSvc) {
        Write-InitLog "windows_exporter service already present (Status=$($existingSvc.Status)) - skipping MSI install, still re-checking firewall/service state below"

        # collector 列表只在 MSI 安装那一刻写进服务的 ImagePath（ENABLED_COLLECTORS 属性），装完
        # 之后改 $WindowsExporterCollectors 重新跑这个脚本，MSI 那步会被上面的分支跳过，不会自动
        # 把新列表套用上去——这里额外比对一次 ImagePath 里实际的 --collectors.enabled 值，
        # 跟配置不一致就直接改注册表 ImagePath 再重启服务，不用整个重装 MSI。改 ImagePath 用
        # Set-ItemProperty 而不是 `sc.exe config`——真机踩过 sc.exe 经 PowerShell 转义带空格路径
        # 会静默传错参数的坑，见 .claude/design.md
        $regPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\windows_exporter'
        $exePath = 'C:\Program Files\windows_exporter\windows_exporter.exe'
        $configPath = 'C:\Program Files\windows_exporter\config.yaml'
        $expectedImagePath = "`"$exePath`" --config.file=`"$configPath`" --collectors.enabled $WindowsExporterCollectors"
        $currentImagePath = (Get-ItemProperty -Path $regPath -Name ImagePath -ErrorAction SilentlyContinue).ImagePath
        if ($currentImagePath -ne $expectedImagePath) {
            Write-InitLog "windows_exporter collector list changed, updating ImagePath and restarting (was: $currentImagePath)"
            Set-ItemProperty -Path $regPath -Name ImagePath -Value $expectedImagePath -Type ExpandString -Force
            Restart-Service -Name windows_exporter -Force
            Start-Sleep -Seconds 3
            $actualImagePath = (Get-ItemProperty -Path $regPath -Name ImagePath -ErrorAction SilentlyContinue).ImagePath
            if ($actualImagePath -ne $expectedImagePath) {
                Write-InitError "ImagePath at $regPath does not read back as expected after setting it (expected: $expectedImagePath, got: $actualImagePath)"
                exit 1
            }
        }
    } else {
        $logPath = Join-Path $env:TEMP 'windows_exporter_install.log'
        $msiArgs = @('/i', $msiPath, '/quiet', '/qn', '/norestart', '/l*v', $logPath, "ENABLED_COLLECTORS=$WindowsExporterCollectors")
        Write-InitLog "Installing windows_exporter $WindowsExporterVersion (collectors: $WindowsExporterCollectors)"
        $proc = Start-Process -FilePath msiexec.exe -ArgumentList $msiArgs -Wait -PassThru
        if ($proc.ExitCode -ne 0) {
            Write-InitError "msiexec exited with code $($proc.ExitCode), see $logPath for details"
            exit 1
        }
        Start-Sleep -Seconds 3
    }

    $svc = Get-Service -Name windows_exporter -ErrorAction SilentlyContinue
    if (-not $svc) {
        Write-InitError 'windows_exporter service not found after install'
        exit 1
    }
    if ($svc.StartType -ne 'Automatic') { Set-Service -Name windows_exporter -StartupType Automatic }
    if ($svc.Status -ne 'Running') { Start-Service -Name windows_exporter }

    $svc = Get-Service -Name windows_exporter
    if ($svc.Status -ne 'Running' -or $svc.StartType -ne 'Automatic') {
        Write-InitError "windows_exporter service is not Running/Automatic after configuration (Status=$($svc.Status), StartType=$($svc.StartType))"
        exit 1
    }

    $ruleName = 'windows_exporter Prometheus Allow'
    $existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
    if ($existingRule) {
        Set-NetFirewallRule -DisplayName $ruleName -Enabled True -Action Allow -Direction Inbound -Protocol TCP -LocalPort $WindowsExporterPort
        Get-NetFirewallRule -DisplayName $ruleName | Set-NetFirewallAddressFilter -RemoteAddress $PrometheusServerIP
    } else {
        New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort $WindowsExporterPort `
            -RemoteAddress $PrometheusServerIP -Action Allow | Out-Null
    }

    # MSI 装的时候没传 ADDLOCAL=FirewallException，正常不会自建规则；这里兜底关掉任何其他名字里
    # 带 windows_exporter 的规则，防止有人手动加过一条"任意来源"的例外，扩大了暴露面
    $otherRules = Get-NetFirewallRule -DisplayName '*windows_exporter*' -ErrorAction SilentlyContinue |
        Where-Object { $_.DisplayName -ne $ruleName -and $_.Enabled -eq 'True' }
    foreach ($r in $otherRules) {
        Write-InitWarn "Disabling unexpected extra windows_exporter firewall rule: $($r.DisplayName)"
        Disable-NetFirewallRule -Name $r.Name
    }

    $actualAddrs = @(Get-NetFirewallRule -DisplayName $ruleName | Get-NetFirewallAddressFilter | Select-Object -ExpandProperty RemoteAddress)
    if ($actualAddrs -notcontains $PrometheusServerIP) {
        Write-InitError "Firewall rule '$ruleName' does not include $PrometheusServerIP after setting it (got: $($actualAddrs -join ', '))"
        exit 1
    }

    Write-InitLog '--- windows_exporter verification ---'
    Write-InitLog "Service: Status=$($svc.Status) StartType=$($svc.StartType)"
    Write-InitLog "Firewall rule '$ruleName' remote addresses: $($actualAddrs -join ', ')"

    try {
        $resp = Invoke-WebRequest -Uri "http://127.0.0.1:$WindowsExporterPort/metrics" -UseBasicParsing -TimeoutSec 5
        Write-InitLog "Local metrics endpoint check: HTTP $($resp.StatusCode), $($resp.Content.Length) bytes"
    } catch {
        Write-InitError "Local metrics endpoint check failed: $($_.Exception.Message)"
        exit 1
    }
}

# ---------- 执行 ----------
Write-InitLog 'admin-init.ps1 start'

if (-not $SkipPasswordChange) { Set-InitAdminPassword } else { Write-InitLog 'SKIP: admin password change' }
if (-not $SkipActivationCheck) { Test-InitActivation } else { Write-InitLog 'SKIP: activation check' }
if (-not $SkipPlayzUser) { New-InitPlayzUser } else { Write-InitLog 'SKIP: playz user setup' }

# RDP 白名单 = Galaxy 运维通用白名单 + 这台机器的专属白名单（-Hostname 给了才拿得到）+
# -WhitelistIPs 手动追加的地址；WinRM 白名单只要运维通用 + -WinRmWhitelistIPs，不掺机器专属
# 白名单，见 .claude/design.md。只在真的要跑步骤 4/5 时才去请求 Galaxy，两步都跳过就不发请求。
# 运维通用白名单请求本身失败（$null，区别于请求成功但列表是空）时不硬停，两个端口都退化成用
# config\settings.ps1 的 $FallbackWhitelistIPs 兜底，不会因为 Galaxy 一时连不上就把两个端口
# 锁到没人能连
$opsWhitelist = @()
if ((-not $SkipFirewall) -or (-not $SkipWinRM)) {
    $opsWhitelist = Get-InitOpsWhitelist
    if ($null -eq $opsWhitelist) {
        Write-InitWarn 'Ops whitelist fetch failed, falling back to $FallbackWhitelistIPs for both RDP and WinRM'
        $opsWhitelist = $FallbackWhitelistIPs
    }
}

$hostWhitelist = @()
if ((-not $SkipFirewall) -and $Hostname) {
    $hostWhitelist = Get-InitHostWhitelist -Hostname $Hostname
}

if (-not $SkipFirewall) { Set-InitFirewallRule -RdpPort $RdpPort -WhitelistIPs ($WhitelistIPs + $opsWhitelist + $hostWhitelist) } else { Write-InitLog 'SKIP: firewall rule' }
if (-not $SkipWinRM) { Set-InitWinRM -WinRmPort $WinRmPort -WinRmWhitelistIPs ($WinRmWhitelistIPs + $opsWhitelist) } else { Write-InitLog 'SKIP: WinRM setup' }
if (-not $SkipRdpTuning) { Set-InitRdpTransportTuning } else { Write-InitLog 'SKIP: RDP transport tuning' }
if (-not $SkipSessionTimeLimits) { Set-InitRdpSessionTimeLimits } else { Write-InitLog 'SKIP: RDP session time limits' }
if (-not $SkipDesktopPerfTweaks) { Set-InitDesktopPerfAndWallpaper } else { Write-InitLog 'SKIP: desktop perf tweaks' }
if (-not $SkipClockSync) { Set-InitClockSyncInterval } else { Write-InitLog 'SKIP: clock sync interval' }
if (-not $SkipWindowsExporter) { Install-InitWindowsExporter } else { Write-InitLog 'SKIP: windows_exporter install' }

Write-InitLog 'admin-init.ps1 done'
