<#
.SYNOPSIS
    AI 遠程維修工具 — Windows 客戶端安裝程式
    安裝 OpenSSH Server + Tailscale,建立維修管理員帳號,並把 SSH 鎖死只走 Tailscale。

.DESCRIPTION
    由維修廠商寄給客戶,客戶以系統管理員身分執行(透過 run.cmd 一鍵提權)。
    執行內容:
      1. 顯示同意畫面(可關閉)。
      2. 安裝 OpenSSH Server(優先用 Windows 內建功能,失敗退回 payload\ 的 MSI,再退回線上下載)。
      3. 設定 sshd:預設 shell = PowerShell、寫入技師公鑰、調整驗證方式與稽核紀錄。
      4. 建立維修管理員帳號(隨機強密碼)。
      5. 安裝 Tailscale 並以 auth key 無人值守加入 tailnet。
      6. 防火牆:移除 Any 來源規則,只允許來自 Tailscale(100.64.0.0/10)的 22 埠。
      7. (可選)安裝 Claude Code、註冊自毀排程。
      8. 驗證並輸出 Tailscale IP / 主機名稱供技師連線。

    所有變更都會記錄在 C:\ProgramData\AIRemoteRepair\install.log。
    維修結束後執行 cleanup.ps1 可完整移除。

.NOTES
    需以系統管理員身分執行。相容 Windows PowerShell 5.1(Windows 10 1809+ / 11)。
#>

[CmdletBinding()]
param(
    # Tailscale 認證金鑰(優先序最高)。未給則讀 authkey.txt → config.json → 環境變數 TS_AUTHKEY。
    [string]$AuthKey,

    # 設定檔路徑。
    [string]$ConfigPath,

    # 跳過同意畫面(用於完全靜默部署;代表客戶已在其他管道同意)。
    [switch]$Silent,

    # 只裝 SSH,不裝 Tailscale(測試用)。
    [switch]$NoTailscale
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# Resolve $ConfigPath in the body, not in the param default. When launched via
# `powershell -File` (which is how run.cmd invokes this script), $PSScriptRoot is
# empty during param-default evaluation, so the old default
# `(Join-Path $PSScriptRoot 'config.json')` threw and aborted before any work ran.
# In the script body $PSScriptRoot is populated correctly.
if (-not $ConfigPath) {
    $ConfigPath = Join-Path $PSScriptRoot 'config.json'
}

# ───────────────────────────────────────── 全域 ─────────────────────────────────────────
$Script:LogDir   = Join-Path $env:ProgramData 'AIRemoteRepair'
$Script:LogFile  = Join-Path $Script:LogDir 'install.log'
$Script:StateFile = Join-Path $Script:LogDir 'state.json'
$Script:InfoFile  = Join-Path $Script:LogDir 'connection-info.json'
# Also mirror the log into the package folder for easy access during testing (ProgramData stays
# the primary copy that cleanup/self-destruct rely on). Best-effort; never holds secrets.
$Script:FolderLogFile = if ($PSScriptRoot) { Join-Path $PSScriptRoot 'install.log' } else { $null }
$TailscaleExe    = Join-Path $env:ProgramFiles 'Tailscale\tailscale.exe'
$SshDir          = Join-Path $env:ProgramData 'ssh'
$SshdConfig      = Join-Path $SshDir 'sshd_config'
$AdminKeysFile   = Join-Path $SshDir 'administrators_authorized_keys'
$OpenSshCapName  = 'OpenSSH.Server~~~~0.0.1.0'
# 安裝過程的安全狀態旗標(供失效安全使用)
$Script:CreatedAccount = $null
$Script:TailscaleUp    = $false

# ───────────────────────────────────────── 工具函式 ─────────────────────────────────────────
function Write-Log {
    param(
        [string]$Message,
        [ValidateSet('INFO', 'WARN', 'ERROR', 'OK', 'STEP')] [string]$Level = 'INFO'
    )
    $ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
    $line = "[$ts] [$Level] $Message"
    if (-not (Test-Path $Script:LogDir)) { New-Item -ItemType Directory -Path $Script:LogDir -Force | Out-Null }
    Add-Content -Path $Script:LogFile -Value $line -Encoding utf8
    if ($Script:FolderLogFile) { try { Add-Content -Path $Script:FolderLogFile -Value $line -Encoding utf8 -ErrorAction Stop } catch {} }
    $color = switch ($Level) {
        'ERROR' { 'Red' }; 'WARN' { 'Yellow' }; 'OK' { 'Green' }; 'STEP' { 'Cyan' }; default { 'Gray' }
    }
    if ($Level -eq 'STEP') { Write-Host '' }
    Write-Host $line -ForegroundColor $color
}

function Assert-Admin {
    $id = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($id)
    if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw '必須以系統管理員身分執行。請改用 run.cmd 雙擊啟動(會自動提權)。'
    }
}

# 安全地執行原生 exe(tailscale/icacls/auditpol)。
# 在 PS 5.1 + $ErrorActionPreference='Stop' 下,原生指令寫 stderr 會中止腳本;
# 這裡暫時改為 Continue 並用 2>&1 合併輸出,再回傳 ExitCode 供呼叫端判斷。
function Invoke-Native {
    param([string]$Exe, [string[]]$Arguments)
    $prev = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'
    try {
        $out = & $Exe @Arguments 2>&1 | ForEach-Object { "$_" }
        return [PSCustomObject]@{ Output = $out; ExitCode = $LASTEXITCODE }
    }
    finally {
        $ErrorActionPreference = $prev
    }
}

function Get-Config {
    if (-not (Test-Path $ConfigPath)) {
        throw "找不到設定檔:$ConfigPath"
    }
    $raw = Get-Content -Path $ConfigPath -Raw -Encoding utf8
    return $raw | ConvertFrom-Json
}

function Get-ConfigValue {
    param($Config, [string]$Name, $Default = $null)
    if ($Config.PSObject.Properties.Name -contains $Name -and $null -ne $Config.$Name -and "$($Config.$Name)" -ne '') {
        return $Config.$Name
    }
    return $Default
}

# Robust boolean config read. NEVER use [bool] on a config value directly: in PS 5.1
# [bool]'false' is $true (any non-empty string is true), so a quoted "false" in config.json
# would silently flip a security flag to its unsafe state (fail-open). Only a real boolean
# or the literal strings true/1 (or false/0) are honored; anything else falls back to $Default.
function Get-ConfigBool {
    param($Config, [string]$Name, [bool]$Default)
    $v = Get-ConfigValue $Config $Name $null
    if ($null -eq $v) { return $Default }
    if ($v -is [bool]) { return $v }
    switch ("$v".Trim().ToLower()) {
        'true'  { return $true }
        '1'     { return $true }
        'false' { return $false }
        '0'     { return $false }
        default { return $Default }
    }
}

function Resolve-AuthKey {
    param($Config)
    if ($AuthKey) { return $AuthKey.Trim() }
    $file = Join-Path $PSScriptRoot 'authkey.txt'
    if (Test-Path $file) {
        # Get-Content -Raw on a 0-byte file returns $null in PS 5.1; guard before .Trim()
        # so an empty/placeholder authkey.txt falls through to config/env instead of throwing.
        $raw = Get-Content -Path $file -Raw
        if ($raw) {
            $k = $raw.Trim()
            if ($k) { return $k }
        }
    }
    $cfgKey = Get-ConfigValue $Config 'TailscaleAuthKey'
    if ($cfgKey) { return "$cfgKey".Trim() }
    if ($env:TS_AUTHKEY) { return $env:TS_AUTHKEY.Trim() }
    return $null
}

function Get-Arch {
    $a = $env:PROCESSOR_ARCHITECTURE
    if ($env:PROCESSOR_ARCHITEW6432) { $a = $env:PROCESSOR_ARCHITEW6432 }
    switch ($a) {
        'AMD64' { 'amd64' }
        'ARM64' { 'arm64' }
        'x86'   { 'x86' }
        default { 'amd64' }
    }
}

function Test-FileHash {
    # -AllowUnset: when no expected hash is configured, warn and pass (used for our own
    # bundled payload, which we trust). Without it, a missing expected hash is fail-closed
    # (used for online downloads) so an unverifiable installer is never silently installed.
    param([string]$Path, [string]$Expected, [switch]$AllowUnset)
    if (-not $Expected) {
        if ($AllowUnset) {
            Write-Log "未設定雜湊值,略過 $([IO.Path]::GetFileName($Path)) 的完整性驗證(建議在 config.json 設定)。" 'WARN'
            return $true
        }
        throw "缺少預期雜湊值,無法驗證 $([IO.Path]::GetFileName($Path)) 的完整性。請在 config.json 設定對應的 Expected*Sha256,或設 AllowUnverifiedDownload=true 明示接受未驗證下載的風險。"
    }
    $actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash
    if ($actual -ne $Expected.ToUpper()) {
        throw "雜湊不符:$Path`n  預期 $Expected`n  實際 $actual"
    }
    Write-Log "雜湊驗證通過:$([IO.Path]::GetFileName($Path))" 'OK'
    return $true
}

# Verify an online-downloaded installer before running it. When AllowUnverifiedDownload
# is true the operator has explicitly opted into the supply-chain risk, so we only warn;
# otherwise the download must match the pinned hash (fail-closed via Test-FileHash).
function Confirm-OnlineDownload {
    param([string]$Path, [string]$Expected)
    if ($Script:AllowUnverifiedDownload) {
        Write-Log "AllowUnverifiedDownload=true:略過線上下載 $([IO.Path]::GetFileName($Path)) 的完整性驗證(風險自負)。" 'WARN'
        return
    }
    Test-FileHash -Path $Path -Expected $Expected | Out-Null
}

# Return the expected Tailscale MSI SHA256 for the given architecture. amd64 keeps the original
# ExpectedTailscaleSha256 config name for backward compatibility; arm64/x86 use their own. x86
# returns its own (usually unset) hash rather than amd64's, so an x86 install isn't guaranteed to
# fail a mismatch (bundled → warn+pass via -AllowUnset; online → fail-closed unless allowed).
function Get-ExpectedTailscaleHash {
    param([string]$Arch)
    switch ($Arch) {
        'arm64' { return $Script:ExpectedTailscaleArm64Sha256 }
        'x86'   { return $Script:ExpectedTailscaleX86Sha256 }
        default { return $Script:ExpectedTailscaleSha256 }
    }
}

# Strictly validate an SSH public key line ("<type> <base64> [comment]"). Prefix matching
# alone lets junk (e.g. a placeholder line) reach administrators_authorized_keys; this
# additionally base64-decodes the blob and checks the SSH wire-format key type matches.
function Test-SshPublicKey {
    param([string]$Line)
    $parts = $Line.Trim() -split '\s+'
    if ($parts.Count -lt 2) { return $false }
    $type = $parts[0]
    $b64  = $parts[1]
    $validTypes = @(
        'ssh-ed25519', 'ssh-rsa',
        'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
        'sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nistp256@openssh.com'
    )
    # Case-SENSITIVE: OpenSSH parses the authorized_keys type token case-sensitively (e.g. 'SSH-RSA'
    # resolves to KEY_UNSPEC and is rejected at login), so a mixed-case token must not validate here.
    if ($type -cnotin $validTypes) { return $false }
    try { $blob = [Convert]::FromBase64String($b64) } catch { return $false }
    if ($blob.Length -lt 4) { return $false }
    # SSH wire format: uint32 big-endian length, then the key-type string. Must equal $type.
    $len = ([int]$blob[0] -shl 24) -bor ([int]$blob[1] -shl 16) -bor ([int]$blob[2] -shl 8) -bor [int]$blob[3]
    if ($len -le 0 -or $len -gt 64 -or $blob.Length -lt (4 + $len)) { return $false }
    $embeddedType = [Text.Encoding]::ASCII.GetString($blob, 4, $len)
    return ($embeddedType -ceq $type)
}

function New-RandomPassword {
    param([int]$Length = 24)
    # 保證含大小寫、數字、符號,滿足 Windows 複雜度原則。相容 .NET Framework(PS 5.1)。
    $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider
    $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
    $lower = 'abcdefghijkmnopqrstuvwxyz'
    $digit = '23456789'
    $spec  = '!@#$%^&*()-_=+'
    $all   = ($upper + $lower + $digit + $spec).ToCharArray()
    function Pick($set) {
        $b = New-Object 'byte[]' 1
        $rng.GetBytes($b)
        $set[$b[0] % $set.Length]
    }
    $chars = @((Pick $upper.ToCharArray()), (Pick $lower.ToCharArray()), (Pick $digit.ToCharArray()), (Pick $spec.ToCharArray()))
    for ($i = $chars.Count; $i -lt $Length; $i++) { $chars += (Pick $all) }
    # 洗牌
    for ($i = $chars.Count - 1; $i -gt 0; $i--) {
        $b = New-Object 'byte[]' 1; $rng.GetBytes($b); $j = $b[0] % ($i + 1)
        $tmp = $chars[$i]; $chars[$i] = $chars[$j]; $chars[$j] = $tmp
    }
    -join $chars
}

function Set-State {
    param([hashtable]$Patch)
    $state = @{}
    if (Test-Path $Script:StateFile) {
        try { (Get-Content $Script:StateFile -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $state[$_.Name] = $_.Value } }
        catch {
            # A corrupt/truncated state.json must NOT silently flip persisted flags (e.g.
            # OpenSSHInstalledByUs true->false). Back it up and warn rather than discard quietly.
            $bak = "$Script:StateFile.corrupt"
            try { Copy-Item $Script:StateFile $bak -Force -ErrorAction Stop } catch {}
            Write-Log "state.json 解析失敗,已備份為 $bak 並以空白狀態重建(先前安裝旗標可能遺失)。" 'WARN'
        }
    }
    foreach ($k in $Patch.Keys) { $state[$k] = $Patch[$k] }
    # Atomic write: write to a temp file then replace, so an interrupted write can never leave a
    # truncated state.json that a later read would silently treat as "no prior state".
    $tmp = "$Script:StateFile.tmp"
    $state | ConvertTo-Json -Depth 5 | Set-Content -Path $tmp -Encoding utf8
    Move-Item -Path $tmp -Destination $Script:StateFile -Force
}

# ───────────────────────────────────────── 安裝步驟 ─────────────────────────────────────────
function Show-Consent {
    param($Config)
    $product = Get-ConfigValue $Config 'ProductName' '專業遠端維修'
    $vendor = Get-ConfigValue $Config 'VendorName' '維修廠商'
    $acct   = Get-ConfigValue $Config 'RepairAccountName' 'svc-vendor-repair'
    Write-Host ''
    Write-Host '════════════════════════════════════════════════════════════════' -ForegroundColor Cyan
    Write-Host "  $product — 安裝同意" -ForegroundColor Cyan
    Write-Host '════════════════════════════════════════════════════════════════' -ForegroundColor Cyan
    Write-Host ''
    Write-Host "  廠商:$vendor"
    Write-Host ''
    Write-Host '  本程式將在這台電腦上進行以下變更,以便進行遠端維修:'
    Write-Host '    • 安裝 OpenSSH 伺服器(允許遠端登入這台電腦)'
    Write-Host '    • 安裝 Tailscale 並加入廠商的私有網路(用來建立加密連線)'
    Write-Host "    • 建立一個維修專用的系統管理員帳號:$acct"
    Write-Host '    • 設定防火牆,讓 SSH 連線「只能」透過 Tailscale 私有網路進入'
    Write-Host '    • 開啟登入稽核紀錄'
    if ((Get-ConfigBool $Config 'HideAccountFromLoginScreen' $false)) {
        Write-Host "    • 此維修帳號將「不顯示」在 Windows 登入畫面(維修完成後會還原)" -ForegroundColor Yellow
    }
    Write-Host ''
    Write-Host '  維修完成後,廠商會執行 cleanup.ps1 移除以上所有變更。' -ForegroundColor Gray
    Write-Host '  您隨時可自行執行 cleanup.ps1 終止存取。' -ForegroundColor Gray
    Write-Host ''
    $ans = Read-Host '  您同意進行以上安裝嗎?輸入 Y 繼續,其他鍵取消'
    if ($ans -notmatch '^[Yy]') {
        Write-Log '使用者未同意,安裝取消。' 'WARN'
        return $false
    }
    Write-Log "使用者已同意安裝(廠商:$vendor,帳號:$acct)。" 'OK'
    return $true
}

function Install-OpenSSH {
    param($Config)
    Write-Log '步驟 1/6:安裝 OpenSSH Server' 'STEP'

    # 判斷是否已存在:內建功能已安裝,或已有 sshd 服務(代表先前用 MSI 裝過)
    $cap = $null
    try { $cap = Get-WindowsCapability -Online -Name $OpenSshCapName -ErrorAction Stop } catch {}
    $capInstalled = ($cap -and $cap.State -eq 'Installed')
    $svcExists = [bool](Get-Service sshd -ErrorAction SilentlyContinue)

    # 保留先前已記錄的 OpenSSHInstalledByUs=$true(避免重跑時被覆寫成 false)
    $prevByUs = $false
    if (Test-Path $Script:StateFile) {
        try { $prevByUs = [bool](Get-Content $Script:StateFile -Raw | ConvertFrom-Json).OpenSSHInstalledByUs } catch {}
    }

    if ($capInstalled -or $svcExists) {
        Write-Log 'OpenSSH Server 已存在,沿用既有安裝(不會在收尾時移除)。' 'OK'
        Set-State @{ OpenSSHInstalledByUs = $prevByUs }
        return
    }

    $done = $false
    $method = $null

    function Install-OpenSSHMsi([string]$Path) {
        Write-Log "以 MSI 安裝 OpenSSH:$Path"
        $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$Path`"", 'ADDLOCAL=Server', '/qn', '/norestart' -Wait -PassThru
        if ($p.ExitCode -notin 0, 3010) { throw "OpenSSH MSI 安裝失敗,exit=$($p.ExitCode)" }
        Write-Log 'MSI 安裝成功。' 'OK'
    }

    # 優先:內附 MSI(快、離線、不依賴 Windows Update —— 這是最可靠的路徑)
    $bundled = Join-Path $PSScriptRoot 'payload\OpenSSH-Win64.msi'
    if (Test-Path $bundled) {
        Test-FileHash -Path $bundled -Expected $Script:ExpectedOpenSSHSha256 -AllowUnset | Out-Null
        Install-OpenSSHMsi $bundled
        $done = $true; $method = 'msi-bundled'
    }

    # 次選:Windows 內建功能,但加逾時保護。
    # Add-WindowsCapability 會向 Windows Update 下載 FoD,在 WU 慢/被擋時可能長時間卡住而「不丟錯」,
    # 導致整個安裝停住。用背景工作 + Wait-Job 逾時,逾時就改走下載 MSI,避免卡死。
    if (-not $done) {
        $timeout = [int](Get-ConfigValue $Config 'CapabilityTimeoutSec' 180)
        Write-Log "嘗試以 Windows 內建功能安裝(最多等候 $timeout 秒)..."
        $job = Start-Job -ScriptBlock { param($n) Add-WindowsCapability -Online -Name $n -ErrorAction Stop } -ArgumentList $OpenSshCapName
        if (Wait-Job $job -Timeout $timeout) {
            try {
                Receive-Job $job -ErrorAction Stop | Out-Null
                $done = $true; $method = 'capability'
                Write-Log '內建功能安裝成功。' 'OK'
            }
            catch { Write-Log "內建功能安裝失敗:$($_.Exception.Message)" 'WARN' }
        }
        else {
            Stop-Job $job -ErrorAction SilentlyContinue
            Write-Log "內建功能安裝逾時($timeout 秒未完成),改用線上下載 MSI。" 'WARN'
        }
        Remove-Job $job -Force -ErrorAction SilentlyContinue
    }

    # 最終退路:線上下載 GitHub 釋出版的 MSI(需網路)
    if (-not $done) {
        Write-Log '線上下載 Win32-OpenSSH MSI...' 'WARN'
        try {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            $rel = Invoke-RestMethod 'https://api.github.com/repos/PowerShell/Win32-OpenSSH/releases/latest' -UseBasicParsing
            $url = ($rel.assets | Where-Object { $_.name -like 'OpenSSH-Win64-v*.msi' } | Select-Object -First 1).browser_download_url
            if (-not $url) { throw '釋出資產中找不到 OpenSSH-Win64 MSI。' }
            $msi = Join-Path $env:TEMP 'OpenSSH-Win64.msi'
            Invoke-WebRequest -Uri $url -OutFile $msi -UseBasicParsing
        }
        catch {
            throw "OpenSSH 安裝失敗(內建功能逾時/失敗且無法下載 MSI):$($_.Exception.Message)。離線環境請把 OpenSSH-Win64.msi 放進 payload\ 後重試。"
        }
        Confirm-OnlineDownload -Path $msi -Expected $Script:ExpectedOpenSSHSha256
        Install-OpenSSHMsi $msi
        $done = $true; $method = 'msi-download'
    }

    # 只有「確實由本工具成功安裝」才記 true(供收尾判斷是否該移除)
    Set-State @{ OpenSSHInstalledByUs = $true; OpenSSHInstallMethod = $method }
}

function Start-Sshd {
    # 等 sshd 服務在 SCM 註冊(剛安裝可能有短暫延遲),再設定並啟動。
    $svc = $null
    for ($i = 0; $i -lt 15; $i++) {
        $svc = Get-Service sshd -ErrorAction SilentlyContinue
        if ($svc) { break }
        Start-Sleep -Seconds 1
    }
    if (-not $svc) { throw '找不到 sshd 服務(OpenSSH 安裝可能未完成)。' }

    Set-Service -Name sshd -StartupType Automatic
    try { Set-Service -Name ssh-agent -StartupType Manual } catch {}
    if ($svc.Status -ne 'Running') { Start-Service sshd }

    # 第一次啟動會產生 host key 與預設 sshd_config;等它出現
    for ($i = 0; $i -lt 15 -and -not (Test-Path $SshdConfig); $i++) { Start-Sleep -Seconds 1 }
    if (-not (Test-Path $SshdConfig)) { throw "sshd 已啟動但找不到設定檔:$SshdConfig" }
    Write-Log 'sshd 服務已啟動,設定檔已產生。' 'OK'
}

function Set-SshdDirective {
    param([string[]]$Lines, [string]$Name, [string]$Value)
    $pattern = "^\s*#?\s*$Name\s+.*$"
    if ($Lines -match $pattern) {
        return ($Lines -replace $pattern, "$Name $Value")
    }
    # 插入到第一個 Match 區塊之前(關鍵:避免落入 Match Group administrators 區塊內)
    $matchLine = $Lines | Select-String -Pattern '^\s*Match\b' | Select-Object -First 1
    if ($matchLine) {
        $idx = $matchLine.LineNumber  # 1-based
        # 防呆:idx=1 時 $Lines[0..($idx-2)] 會變成 0..-1(PowerShell 會展開成整段),用 @() 守住空前段
        $prefix = if ($idx -gt 1) { @($Lines[0..($idx - 2)]) } else { @() }
        $suffix = @($Lines[($idx - 1)..($Lines.Count - 1)])
        return @($prefix) + "$Name $Value" + @($suffix)
    }
    return @($Lines) + "$Name $Value"
}

function Configure-Sshd {
    param($Config)
    Write-Log '步驟 3/6:設定 sshd(shell / 公鑰 / 驗證 / 稽核)' 'STEP'

    # 確保 sshd_config 已產生(正常情況 Start-Sshd 已保證)
    if (-not (Test-Path $SshdConfig)) {
        Start-Service sshd -ErrorAction SilentlyContinue
        Start-Sleep -Seconds 2
    }

    # 編輯前先備份原始設定檔,供收尾還原(只備份一次)
    $bak = "$SshdConfig.airr.bak"
    if ((Test-Path $SshdConfig) -and -not (Test-Path $bak)) {
        Copy-Item $SshdConfig $bak -Force
        Write-Log '已備份原始 sshd_config(收尾時還原)。'
    }

    # 預設 shell 設為 Windows PowerShell
    if (-not (Test-Path 'HKLM:\SOFTWARE\OpenSSH')) {
        New-Item -Path 'HKLM:\SOFTWARE' -Name 'OpenSSH' -Force | Out-Null
    }
    # Back up any customer-set DefaultShell BEFORE overwriting, once, so cleanup can restore it
    # (a pre-existing OpenSSH customer may rely on a custom shell). Only record on first touch.
    $alreadyBackedUpShell = $false
    if (Test-Path $Script:StateFile) {
        try { $alreadyBackedUpShell = [bool](Get-Content $Script:StateFile -Raw | ConvertFrom-Json).PSObject.Properties['PrevDefaultShell'] } catch {}
    }
    if (-not $alreadyBackedUpShell) {
        # StrictMode-safe read: fetch the key, then only read DefaultShell if that value exists
        # (a pre-existing OpenSSH key usually has NO DefaultShell value, and .DefaultShell on a
        # missing property throws under Set-StrictMode).
        $prevShell = ''
        $opensshKey = Get-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue
        if ($opensshKey -and ($opensshKey.PSObject.Properties.Name -contains 'DefaultShell')) {
            $prevShell = "$($opensshKey.DefaultShell)"
        }
        Set-State @{ PrevDefaultShell = $prevShell }
    }
    New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name 'DefaultShell' `
        -Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -PropertyType String -Force | Out-Null
    Write-Log '預設 SSH shell 設為 Windows PowerShell。' 'OK'

    # Whether THIS tool installed OpenSSH (vs. the customer already had it). Drives both how we
    # write admin keys (replace vs. preserve+restore) and whether to add an AllowUsers restriction.
    $byUs = $false
    if (Test-Path $Script:StateFile) {
        try { $byUs = [bool](Get-Content $Script:StateFile -Raw | ConvertFrom-Json).OpenSSHInstalledByUs } catch {}
    }

    $authMode = (Get-ConfigValue $Config 'AuthMode' 'both').ToString().ToLower()

    # 安裝技師公鑰(key / both 模式,或 password 模式但有提供公鑰)
    $pubPath = Join-Path $PSScriptRoot 'techpubkey.txt'
    $havePub = $false
    if (Test-Path $pubPath) {
        $pub = (Get-Content -Path $pubPath -Raw)
        # Validate each non-comment line as a real SSH public key (base64 + wire-format type),
        # so placeholder/garbage lines that merely look like a key are rejected, not installed.
        $pubLines = @()
        foreach ($line in ($pub -split "`r?`n")) {
            $t = $line.Trim()
            if (-not $t -or $t.StartsWith('#')) { continue }
            if (Test-SshPublicKey $t) {
                $pubLines += $t
            }
            elseif ($t -match '^(ssh-|ecdsa-|sk-)') {
                $preview = $t.Substring(0, [Math]::Min(40, $t.Length))
                Write-Log "techpubkey.txt 有一行看似公鑰但驗證失敗(格式/base64 無效),已略過:$preview..." 'WARN'
            }
        }
        if ($pubLines) {
            $pub = ($pubLines -join "`n")
            $adminKeysBak = "$AdminKeysFile.airr.bak"

            # Decide ONCE, and PERSIST, whether this admin-keys file is ours to delete on cleanup
            # or the customer's to restore. Relying on ".bak exists" alone is ambiguous on install
            # re-runs and on a second cleanup, which could either leave our technician key behind
            # (a backdoor) or delete the customer's restored keys. The persisted flag removes the
            # ambiguity; the backup is additionally stripped of OUR keys as defense-in-depth.
            $createdByUs = $null
            if (Test-Path $Script:StateFile) {
                try {
                    $st = Get-Content $Script:StateFile -Raw | ConvertFrom-Json
                    if ($st.PSObject.Properties.Name -contains 'AdminKeysCreatedByUs') { $createdByUs = [bool]$st.AdminKeysCreatedByUs }
                } catch {}
            }
            if ($null -eq $createdByUs) {
                # First touch. Treat the file as the customer's only if it contains key lines that
                # are NOT ours; back those up (without our keys) so cleanup restores exactly them.
                $existingLines = @()
                if (Test-Path $AdminKeysFile) { $existingLines = @(Get-Content -Path $AdminKeysFile) }
                $ourKeys = @($pubLines)
                $customerLines = @($existingLines | Where-Object { $_.Trim() -and ($ourKeys -notcontains $_.Trim()) })
                if ($customerLines.Count -gt 0) {
                    $createdByUs = $false
                    Set-Content -Path $adminKeysBak -Value ($customerLines -join "`n") -Encoding ascii
                    Write-Log '已備份既有 administrators_authorized_keys(收尾時還原)。'
                }
                else {
                    $createdByUs = $true
                }
            }

            if ($createdByUs) {
                # The file is ours: write only the technician key(s) (re-runs simply overwrite).
                Set-Content -Path $AdminKeysFile -Value $pub -Encoding ascii
            }
            else {
                # Pre-existing customer file: rebuild from the (our-key-free) backup + technician
                # key, so re-runs never duplicate our key and cleanup restores exactly the original.
                $baseKeys = ''
                if (Test-Path $adminKeysBak) { $baseKeys = (Get-Content -Path $adminKeysBak -Raw) }
                $combined = if ("$baseKeys".Trim()) { ("$baseKeys".TrimEnd("`r", "`n") + "`n" + $pub) } else { $pub }
                Set-Content -Path $AdminKeysFile -Value $combined -Encoding ascii
            }
            Set-State @{ AdminKeysCreatedByUs = $createdByUs }
            # 嚴格 ACL:僅 SYSTEM + Administrators(用 SID,跨語系安全)
            $r = Invoke-Native 'icacls.exe' @("$AdminKeysFile", '/inheritance:r', '/grant', '*S-1-5-32-544:F', '/grant', '*S-1-5-18:F')
            if ($r.ExitCode -ne 0) { Write-Log "icacls 設定 ACL 失敗(exit=$($r.ExitCode)),金鑰登入可能失效:$($r.Output)" 'WARN' }
            $havePub = $true
            Write-Log '技師公鑰已寫入 administrators_authorized_keys 並套用嚴格 ACL。' 'OK'
        }
    }
    if (-not $havePub -and $authMode -in @('key', 'both')) {
        if ($authMode -eq 'key') {
            throw "AuthMode=key 但找不到有效的 techpubkey.txt 公鑰。請放入技師的 ed25519 公鑰,或將 AuthMode 改為 password。"
        }
        Write-Log 'AuthMode=both 但未提供公鑰,將以密碼驗證為主。' 'WARN'
    }

    # 決定 PasswordAuthentication
    $passwordAuth = switch ($authMode) {
        'key'      { 'no' }
        'password' { 'yes' }
        default    { 'yes' }   # both
    }

    $lines = Get-Content $SshdConfig
    $lines = Set-SshdDirective $lines 'PasswordAuthentication' $passwordAuth
    $lines = Set-SshdDirective $lines 'PubkeyAuthentication'  'yes'
    $lines = Set-SshdDirective $lines 'PermitRootLogin'       'no'
    $lines = Set-SshdDirective $lines 'LogLevel'              'VERBOSE'
    $lines = Set-SshdDirective $lines 'SyslogFacility'        'LOCAL0'

    # 一律限制只有維修帳號能透過 SSH 登入(即使 OpenSSH 為客戶既有)。
    # 關鍵安全理由:技師公鑰寫在 administrators_authorized_keys,經 "Match Group administrators"
    # 後會對「所有」系統管理員帳號生效。若略過 AllowUsers,停用/不啟用維修帳號仍擋不住技師金鑰
    # 以其他管理員帳號登入,違反「停用帳號 = 阻斷存取」的失效安全/Set-RepairSession 保證。
    # SSH 已被防火牆鎖到只走 Tailscale,且 cleanup 會從 .airr.bak 還原原始 sshd_config
    # (含客戶原本的 AllowUsers,如有),故不影響收尾後客戶的既有用途。($byUs 已於函式前段計算)
    $acct = Get-ConfigValue $Config 'RepairAccountName' 'svc-vendor-repair'
    $lines = Set-SshdDirective $lines 'AllowUsers' $acct
    if ($byUs) {
        Write-Log "已限制 SSH 僅允許帳號登入:$acct" 'OK'
    }
    else {
        Write-Log "已限制 SSH 僅允許帳號登入:$acct(偵測到既有 OpenSSH;cleanup 會還原原始 sshd_config 與客戶原本的設定)。" 'OK'
    }

    # Ensure admin pubkey auth routes to administrators_authorized_keys. Some pre-existing OpenSSH
    # configs ship this Match block COMMENTED OUT (e.g. "#### Match Group administrators"), so an
    # administrator's key in administrators_authorized_keys is never consulted and key login fails
    # with "Permission denied (publickey)". Append an uncommented block when no ACTIVE one exists.
    # (cleanup.ps1 restores the original sshd_config from backup afterwards.)
    if (($lines -join "`n") -notmatch '(?im)^[ \t]*Match[ \t]+Group[ \t]+administrators\b') {
        $lines = @($lines) + 'Match Group administrators' + '       AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys'
        Write-Log '已補上 Match Group administrators 區塊(既有 sshd_config 缺少或已註解,否則管理員金鑰登入會失效)。' 'OK'
    }

    $lines | Set-Content -Path $SshdConfig -Encoding ascii
    Restart-Service sshd
    Write-Log "sshd 設定完成(PasswordAuthentication $passwordAuth,已重啟服務)。" 'OK'
}

function New-RepairAccount {
    param($Config)
    Write-Log '步驟 4/6:建立維修管理員帳號(先停用,確認安全後才啟用)' 'STEP'

    $acct = Get-ConfigValue $Config 'RepairAccountName' 'svc-vendor-repair'
    $full = Get-ConfigValue $Config 'RepairAccountFullName' 'Vendor Remote Repair (consented)'

    $pwPlain = New-RandomPassword -Length 24
    $pw = ConvertTo-SecureString $pwPlain -AsPlainText -Force

    $existing = Get-LocalUser -Name $acct -ErrorAction SilentlyContinue
    if ($existing) {
        Set-LocalUser -Name $acct -Password $pw
        Write-Log "帳號已存在,已重設密碼:$acct" 'WARN'
    }
    else {
        New-LocalUser -Name $acct -Password $pw -FullName $full `
            -Description 'Created by AI Remote Repair installer' `
            -PasswordNeverExpires -AccountNeverExpires | Out-Null
        Write-Log "已建立帳號:$acct" 'OK'
    }

    # Record the account for the fail-safe IMMEDIATELY after it exists (it is created ENABLED),
    # BEFORE the group-add/disable steps below. If any of those throw, the main catch can still
    # disable this account; otherwise a partially-created enabled admin account would survive.
    $Script:CreatedAccount = $acct

    # 加入系統管理員群組(用 SID 取群組名,跨語系安全)。
    # WinPS 5.1 的 Get-LocalGroupMember 在群組含孤兒 SID 時會丟例外,故直接嘗試新增並忽略「已是成員」。
    $adminGroup = (Get-LocalGroup -SID 'S-1-5-32-544').Name
    try {
        Add-LocalGroupMember -Group $adminGroup -Member $acct -ErrorAction Stop
        Write-Log "已加入群組:$adminGroup" 'OK'
    }
    catch {
        if ($_.FullyQualifiedErrorId -match 'MemberExists' -or $_.Exception.Message -match 'already a member|已經是') {
            Write-Log "帳號已是 $adminGroup 成員。" 'OK'
        }
        else { throw }
    }

    # 一律先停用;待 Tailscale 連線 + 防火牆鎖定確認後,最後再由 Enable-RepairAccount 啟用。
    # ($Script:CreatedAccount 已於帳號建立後立即設定,供失效安全使用。)
    Disable-LocalUser -Name $acct
    Set-State @{ RepairAccountName = $acct }
    # 可選(預設關閉):自登入畫面隱藏此帳號。需客戶同意,cleanup 會還原。
    Set-RepairAccountVisibility $Config $acct
    Write-Log '帳號已建立並暫時停用。' 'OK'
    return @{ Account = $acct; Password = $pwPlain }
}

function Set-RepairAccountVisibility {
    param($Config, [string]$Account)
    # OFF by default. Hiding the account from the login screen conflicts with this tool's
    # transparency/consent design, so it is strictly opt-in via config and must be disclosed
    # to the customer. cleanup.ps1 removes the UserList entry (independent of state).
    $key = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList'
    if (-not (Get-ConfigBool $Config 'HideAccountFromLoginScreen' $false)) {
        # Bidirectional idempotency: if the flag was flipped back to false on a re-run, un-hide.
        if ((Test-Path $key) -and ($null -ne (Get-ItemProperty -Path $key -Name $Account -ErrorAction SilentlyContinue))) {
            Remove-ItemProperty -Path $key -Name $Account -ErrorAction SilentlyContinue
            Set-State @{ AccountHidden = $false }
            Write-Log "HideAccountFromLoginScreen=false:已取消登入畫面隱藏:$Account。" 'OK'
        }
        return
    }
    if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
    New-ItemProperty -Path $key -Name $Account -Value 0 -PropertyType DWord -Force | Out-Null
    Set-State @{ AccountHidden = $true }
    Write-Log "已將維修帳號自 Windows 登入畫面隱藏(HideAccountFromLoginScreen=true):$Account。此選項與透明設計相悖,僅在已取得客戶明確同意下使用。" 'WARN'
}

function Enable-RepairAccount {
    param($Config, [string]$Account)
    if ((Get-ConfigBool $Config 'EnableAccountOnInstall' $true)) {
        Enable-LocalUser -Name $Account
        Write-Log "維修帳號已啟用,可登入:$Account" 'OK'
    }
    else {
        Write-Log "維修帳號維持停用(EnableAccountOnInstall=false);需要時用 Set-RepairSession.ps1 -Enable 開啟。" 'WARN'
    }
}

# Ensure the Tailscale daemon (the 'Tailscale' Windows service / tailscaled) is running and its
# local API actually answers before we drive it with `tailscale up` / `status`. On machines where
# Tailscale was already installed the service may be Stopped or set to Manual/Disabled (and the MSI
# is run with TS_NOLAUNCH=true), in which case the CLI cannot reach the local named-pipe API:
# `status --json` returns empty stdout and `up` fast-fails, surfacing as an EMPTY BackendState that
# the caller would otherwise misreport as an auth-key problem. Returns $true once the local API
# responds (status --json parses), else $false after $TimeoutSec.
function Wait-TailscaleReady {
    param([int]$TimeoutSec = 60)

    $svc = Get-Service -Name 'Tailscale' -ErrorAction SilentlyContinue
    if (-not $svc) {
        # Older builds may register a different service short name; match by display name as fallback.
        $svc = Get-Service -DisplayName 'Tailscale*' -ErrorAction SilentlyContinue | Select-Object -First 1
    }
    if ($svc) {
        # A Disabled service refuses Start-Service; flip it to Automatic first (also survives reboot).
        try { if ($svc.StartType -eq 'Disabled') { Set-Service -Name $svc.Name -StartupType Automatic } } catch {}
        if ($svc.Status -ne 'Running') {
            Write-Log "Tailscale 服務($($svc.Name))未執行,正在啟動 daemon..." 'WARN'
            try { Start-Service -Name $svc.Name -ErrorAction Stop }
            catch { Write-Log "  啟動 Tailscale 服務失敗:$($_.Exception.Message)" 'WARN' }
        }
    }
    else {
        Write-Log '找不到 Tailscale 服務;將直接嘗試與 daemon 通訊。' 'WARN'
    }

    # A 'Running' service does not guarantee the local API is serving yet; poll until status --json
    # parses (i.e. the CLI can actually reach tailscaled) before we attempt the join.
    $deadline = (Get-Date).AddSeconds($TimeoutSec)
    while ((Get-Date) -lt $deadline) {
        $json = (& $TailscaleExe status --json 2>$null | Out-String)
        try { if ($json | ConvertFrom-Json) { return $true } } catch {}
        Start-Sleep -Seconds 2
    }
    return $false
}

# Emit Tailscale connectivity diagnostics (netcheck + status) into the log on a join failure, so
# environment problems — can't reach control/DERP, DNS, wrong clock, blocked by AV/firewall — leave
# actionable evidence instead of just a timeout. Best-effort and uses Invoke-Native so a native
# stderr write can't abort the script before the caller throws.
function Write-TailscaleDiagnostics {
    $nc = ((@((Invoke-Native $TailscaleExe @('netcheck')).Output) | Where-Object { $_ }) -join "`n").Trim()
    if ($nc) { Write-Log "Tailscale netcheck(連線健檢):`n$nc" 'WARN' }
    $st = ((@((Invoke-Native $TailscaleExe @('status')).Output) | Where-Object { $_ }) -join "`n").Trim()
    if ($st) { Write-Log "Tailscale status:`n$st" 'WARN' }
}

# Clear any leftover Tailscale identity so `tailscale up` registers with FRESH keys. A machine that
# previously joined another tailnet (or whose uninstall/reinstall preserved tailscaled.state) can
# present a node/machine key the control plane already has, so `tailscale up` fails with
# "register request: http 400: node ... already exists" and hangs at NoState. Call this ONLY after the
# OverrideExistingTailnet guard has cleared (we've decided to (re)bind this machine to the vendor
# tailnet). `logout` rotates the node key; removing tailscaled.state also forces a fresh machine key.
function Reset-TailscaleIdentity {
    Invoke-Native $TailscaleExe @('logout') | Out-Null
    $svc = Get-Service -Name 'Tailscale' -ErrorAction SilentlyContinue
    if (-not $svc) { $svc = Get-Service -DisplayName 'Tailscale*' -ErrorAction SilentlyContinue | Select-Object -First 1 }
    $stateFile = Join-Path $env:ProgramData 'Tailscale\tailscaled.state'
    if ($svc -and (Test-Path $stateFile)) {
        try {
            Stop-Service -Name $svc.Name -Force -ErrorAction Stop
            Remove-Item $stateFile -Force -ErrorAction SilentlyContinue
            Start-Service -Name $svc.Name -ErrorAction SilentlyContinue
        }
        catch { Write-Log "  重置 Tailscale 狀態檔時發生問題(略過,logout 已先執行):$($_.Exception.Message)" 'WARN' }
    }
    Wait-TailscaleReady 30 | Out-Null
}

function Install-Tailscale {
    param($Config, [string]$Key)
    Write-Log '步驟 5/6:安裝 Tailscale 並加入私有網路' 'STEP'

    if (-not (Test-Path $TailscaleExe)) {
        $arch = Get-Arch
        $bundled = Join-Path $PSScriptRoot "payload\tailscale-setup-$arch.msi"
        $msi = $null
        if (Test-Path $bundled) {
            Test-FileHash -Path $bundled -Expected (Get-ExpectedTailscaleHash $arch) -AllowUnset | Out-Null
            $msi = $bundled
            Write-Log "使用內附 Tailscale MSI:$bundled"
        }
        else {
            Write-Log "payload 無 Tailscale MSI($arch),線上下載 stable($arch)..." 'WARN'
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            $url = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-$arch.msi"
            $msi = Join-Path $env:TEMP 'tailscale-setup.msi'
            Invoke-WebRequest -Uri $url -OutFile $msi -UseBasicParsing
            Confirm-OnlineDownload -Path $msi -Expected (Get-ExpectedTailscaleHash $arch)
        }
        $log = Join-Path $env:TEMP 'tailscale-install.log'
        $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", 'TS_UNATTENDEDMODE=always', 'TS_NOLAUNCH=true', '/quiet', '/norestart', '/L*v', "`"$log`"" -Wait -PassThru
        if ($p.ExitCode -notin 0, 3010) { throw "Tailscale MSI 安裝失敗,exit=$($p.ExitCode)(log: $log)" }
        Write-Log 'Tailscale 安裝完成。' 'OK'
    }
    else {
        Write-Log 'Tailscale 已安裝。' 'OK'
    }

    if (-not (Test-Path $TailscaleExe)) { throw "找不到 tailscale.exe:$TailscaleExe" }

    # Daemon readiness BEFORE any `tailscale up`/`status`: on 'already installed' machines the
    # Tailscale service may be stopped, so the CLI cannot reach the local API and both `up` and
    # `status` fail with an empty BackendState. Bringing the daemon up here also makes the existing
    # OverrideExistingTailnet pre-check below accurate. Best-effort: warn (don't abort) if not ready,
    # since the explicit `tailscale up` exit-code check downstream will surface the real error.
    if (Wait-TailscaleReady 60) { Write-Log 'Tailscale daemon 已就緒(local API 可連線)。' 'OK' }
    else { Write-Log 'Tailscale daemon 尚未就緒(local API 無回應);仍會嘗試 tailscale up,失敗時下方會顯示實際錯誤。' 'WARN' }

    if (-not $Key) {
        throw 'Tailscale 需要 auth key。請用 -AuthKey 參數、authkey.txt、config.json 的 TailscaleAuthKey,或環境變數 TS_AUTHKEY 提供。'
    }

    # Tailscale hostname: explicit TailscaleHostname wins; else if a JobId is set (legacy per-case)
    # use repair-<JobId>; else (universal single installer) use THIS machine's real computer name so
    # every customer's device is distinguishable on the tailnet (no repair-<generic> collisions).
    $hostname = Get-ConfigValue $Config 'TailscaleHostname'
    if (-not $hostname) {
        $jobId = Get-ConfigValue $Config 'JobId'
        if ($jobId) { $hostname = "repair-$jobId" } else { $hostname = $env:COMPUTERNAME }
    }
    $hostname = ($hostname -replace '[^A-Za-z0-9\-]', '-').ToLower()
    $tag = Get-ConfigValue $Config 'TailscaleTag' 'tag:repair'

    # 偵測客戶是否已登入自己的 tailnet。下面的 `tailscale up --auth-key=... --reset` 會把節點
    # 重新綁到廠商 tailnet 並重置既有偏好,等於把客戶踢出自己的網路且 cleanup 無法還原。
    # 除非操作者在 config.json 明示 OverrideExistingTailnet=true,否則中止以保護客戶既有設定。
    $pre = $null
    try { $pre = (& $TailscaleExe status --json 2>$null | Out-String) | ConvertFrom-Json } catch { $pre = $null }
    $preState = ''
    if ($pre -and ($pre.PSObject.Properties.Name -contains 'BackendState')) { $preState = "$($pre.BackendState)" }
    # The node counts as "already logged into a tailnet" when up (Running/Starting), logged in but
    # currently down ('Stopped' after `tailscale down`), OR carrying a saved CurrentTailnet identity.
    # 'Stopped'/CurrentTailnet were missing before, so a logged-in-but-down customer node fell through
    # to `--reset` and got clobbered despite the guard. NeedsLogin/NoState/empty = not logged in → proceed.
    $preLoggedIn = ($preState -in @('Running', 'Starting', 'Stopped'))
    $preTailnet = '(unknown)'
    try { if ($pre -and ($pre.PSObject.Properties.Name -contains 'CurrentTailnet') -and $pre.CurrentTailnet) { $preLoggedIn = $true; $preTailnet = "$($pre.CurrentTailnet.Name)" } } catch {}
    if ($preLoggedIn -and -not (Get-ConfigBool $Config 'OverrideExistingTailnet' $false)) {
        throw "偵測到本機已登入 Tailscale tailnet '$preTailnet'(狀態:$preState)。為避免覆蓋客戶既有 Tailscale 設定,已中止。確認後可在 config.json 設 OverrideExistingTailnet=true 再執行。"
    }

    # 重置本機 Tailscale 身分,確保以全新 node key 加入(否則舊 node key 會被控制台以 "already
    # exists" 拒絕而卡住)。此步在覆蓋保護檢查「之後」,故不影響對客戶既有 tailnet 的保護。
    Write-Log '重置本機 Tailscale 身分,確保以全新金鑰加入(避免殘留 node key 造成 already exists)...'
    Reset-TailscaleIdentity

    # 把金鑰寫入暫存檔,用 --auth-key=file: 避免出現在命令列/紀錄
    $keyFile = Join-Path $env:TEMP 'ts-authkey.txt'
    Set-Content -Path $keyFile -Value $Key -Encoding ascii -NoNewline
    try {
        Write-Log "加入 tailnet:hostname=$hostname,tag=$tag(unattended)..."
        $r = Invoke-Native $TailscaleExe @(
            'up', "--auth-key=file:$keyFile", '--unattended',
            "--hostname=$hostname", "--advertise-tags=$tag",
            '--accept-routes=false', '--reset', '--timeout=120s'
        )
        $r.Output | ForEach-Object { if ($_) { Write-Log "  tailscale: $_" } }
        # Don't swallow a failed join: surface Tailscale's actual exit code + message instead of
        # falling through to a 30s poll that ends with a generic (often wrong) auth-key guess.
        if ($r.ExitCode -ne 0) {
            $detail = (@($r.Output) | Where-Object { $_ }) -join '; '
            if (-not $detail) { $detail = '(tailscale 無輸出,通常代表 daemon 未執行或無法連線)' }
            Write-TailscaleDiagnostics
            throw "tailscale up 失敗(exit=$($r.ExitCode)):$detail"
        }
    }
    finally {
        Remove-Item $keyFile -Force -ErrorAction SilentlyContinue
    }

    # 驗證連線狀態(up 回傳不代表已 Running,輪詢最多約 30 秒;用 2>$null 取乾淨 JSON)
    $status = $null
    $state = ''
    for ($i = 0; $i -lt 15; $i++) {
        $statusJson = (& $TailscaleExe status --json 2>$null | Out-String)
        try { $status = $statusJson | ConvertFrom-Json } catch { $status = $null }
        # StrictMode-safe property access (mirrors the pre-check guard above): a parsed object that
        # lacks BackendState would otherwise throw under Set-StrictMode -Version Latest.
        $state = if ($status -and ($status.PSObject.Properties.Name -contains 'BackendState')) { "$($status.BackendState)" } else { '' }
        if ($state -eq 'Running') { break }
        Start-Sleep -Seconds 2
    }
    if ($state -ne 'Running') {
        # Diagnose instead of guessing: an EMPTY state means the CLI could not reach tailscaled
        # (service down/unreachable); a concrete state (NeedsLogin/Stopped) points at the auth key.
        # Log the service status and raw `tailscale status` (stderr included) so the failure is actionable.
        $svc = Get-Service -Name 'Tailscale' -ErrorAction SilentlyContinue
        if (-not $svc) { $svc = Get-Service -DisplayName 'Tailscale*' -ErrorAction SilentlyContinue | Select-Object -First 1 }
        $svcState = if ($svc) { "$($svc.Status)" } else { '(服務不存在)' }
        # Log netcheck + status (via Invoke-Native so a native stderr write can't abort before we throw).
        Write-TailscaleDiagnostics
        $hint = if (-not $state) {
            'BackendState 讀不到(空),通常代表 Tailscale 服務(daemon)未執行或本機 API 無法連線。'
        }
        else {
            "目前 BackendState=$state。NeedsLogin/NeedsMachineAuth 多半是 auth key 過期/被撤銷/達上限,或本機連不到 Tailscale 控制伺服器(見上方 netcheck)。"
        }
        throw "Tailscale 未進入 Running 狀態(目前:$state;服務:$svcState)。$hint"
    }
    $tsip = ((& $TailscaleExe ip -4 2>$null) | Select-Object -First 1)
    if ($tsip) { $tsip = "$tsip".Trim() }
    $fqdn = "$($status.Self.DNSName)".TrimEnd('.')
    Write-Log "已加入 tailnet。Tailscale IP=$tsip,FQDN=$fqdn" 'OK'

    $Script:TailscaleUp = $true
    Set-State @{ TailscaleInstalled = $true; TailscaleIP = $tsip; TailscaleFQDN = $fqdn }

    # join 成功後刪除來源 authkey.txt,避免金鑰留在磁碟
    $ak = Join-Path $PSScriptRoot 'authkey.txt'
    if (Test-Path $ak) {
        Remove-Item $ak -Force -ErrorAction SilentlyContinue
        Write-Log 'authkey.txt 已刪除(join 後)。' 'OK'
    }
    if (Get-ConfigValue $Config 'TailscaleAuthKey') {
        Write-Log '提醒:config.json 內仍留有 TailscaleAuthKey,單次金鑰已失效,但建議手動清空該欄位。' 'WARN'
    }

    return @{ IP = $tsip; FQDN = $fqdn }
}

function Set-FirewallLockdown {
    param($Config)
    Write-Log '步驟 2/6:防火牆鎖定(先鎖,再啟動 sshd;SSH 只走 Tailscale)' 'STEP'

    if (-not (Get-ConfigBool $Config 'RestrictToTailscaleOnly' $true)) {
        Write-Log 'RestrictToTailscaleOnly=false,略過防火牆鎖定(SSH 將可從任何來源連入,不建議)。' 'WARN'
        return
    }

    # 1) 移除預設「任何來源」的 OpenSSH 允許規則(Windows 允許規則是 OR 疊加,不刪等於沒鎖)
    Get-NetFirewallRule -Direction Inbound -Action Allow -ErrorAction SilentlyContinue |
        Where-Object { $_.DisplayName -match 'OpenSSH' -or $_.Name -match 'OpenSSH' } |
        ForEach-Object {
            try { Remove-NetFirewallRule -Name $_.Name -ErrorAction Stop; Write-Log "  已移除預設規則:$($_.DisplayName)" } catch {}
        }

    # 1b) 名稱比對不足以鎖死:任何「其他」開放 TCP/22(或 Any 埠)的入站允許規則 —— 客戶自建、
    #     第三方 SSH 軟體、或泛用「放行 22」規則 —— 都會與後面的 Tailscale-only 規則 OR 疊加,
    #     讓 SSH 仍可從非 Tailscale 來源連入。逐一移除(移除失敗則停用),非本工具自己的規則。
    $ourRuleNames = @('sshd-tailscale-only', 'sshd-tailscale-only-v6')
    Get-NetFirewallRule -Direction Inbound -Action Allow -Enabled True -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -notin $ourRuleNames } |
        ForEach-Object {
            $rule = $_
            $opens22 = $false
            foreach ($f in @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)) {
                if ($f.Protocol -eq 'TCP') {
                    $lp = @($f.LocalPort)
                    if ($lp -contains '22' -or $lp -contains 'Any') { $opens22 = $true }
                }
            }
            if ($opens22) {
                try { Remove-NetFirewallRule -Name $rule.Name -ErrorAction Stop; Write-Log "  已移除其他開放 22 埠的允許規則:$($rule.DisplayName)" }
                catch {
                    try { Set-NetFirewallRule -Name $rule.Name -Enabled False -ErrorAction Stop; Write-Log "  已停用其他開放 22 埠的允許規則:$($rule.DisplayName)" 'WARN' } catch {}
                }
            }
        }

    # 2) 只允許來自 Tailscale CGNAT 範圍的 TCP 22
    Get-NetFirewallRule -Name 'sshd-tailscale-only' -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue
    New-NetFirewallRule -Name 'sshd-tailscale-only' -DisplayName 'OpenSSH (sshd) - Tailscale only' `
        -Direction Inbound -Action Allow -Protocol TCP -LocalPort 22 `
        -RemoteAddress '100.64.0.0/10' -Profile Any -Enabled True | Out-Null
    Write-Log '  已新增規則:僅允許 100.64.0.0/10(IPv4)連入 22 埠。' 'OK'

    # 3) IPv6 縱深防禦(Tailscale ULA)
    Get-NetFirewallRule -Name 'sshd-tailscale-only-v6' -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue
    New-NetFirewallRule -Name 'sshd-tailscale-only-v6' -DisplayName 'OpenSSH (sshd) - Tailscale only (IPv6)' `
        -Direction Inbound -Action Allow -Protocol TCP -LocalPort 22 `
        -RemoteAddress 'fd7a:115c:a1e0::/48' -Profile Any -Enabled True | Out-Null
    Write-Log '  已新增規則:僅允許 Tailscale IPv6 連入 22 埠。' 'OK'
    Write-Log 'SSH 現在只能透過 Tailscale 連入。' 'OK'
}

function Enable-Auditing {
    Write-Log '開啟登入稽核紀錄...'
    # 用子類別 GUID 而非名稱,避免非英文系統/空白字元的問題。
    # {0CCE9215-...} = Logon;{0CCE921C-...} = Other Logon/Logoff Events
    $r1 = Invoke-Native 'auditpol.exe' @('/set', '/subcategory:{0CCE9215-69AE-11D9-BED3-505054503030}', '/success:enable', '/failure:enable')
    $r2 = Invoke-Native 'auditpol.exe' @('/set', '/subcategory:{0CCE921C-69AE-11D9-BED3-505054503030}', '/success:enable', '/failure:enable')
    if ($r1.ExitCode -eq 0) {
        Write-Log '已開啟登入稽核(事件 4624/4625;OpenSSH/Operational 紀錄技師金鑰指紋)。' 'OK'
    }
    else {
        Write-Log "稽核設定可能未完全套用(非致命,exit=$($r1.ExitCode)/$($r2.ExitCode))。" 'WARN'
    }
}

function Install-ClaudeCodeOptional {
    param($Config)
    if (-not (Get-ConfigBool $Config 'InstallClaudeCode' $false)) {
        Write-Log 'InstallClaudeCode=false,跳過(建議由技師連線後再依需求安裝)。'
        return
    }
    Write-Log '安裝 Claude Code(可選)...' 'STEP'
    try {
        $env:DISABLE_AUTOUPDATER = '1'
        Invoke-RestMethod 'https://claude.ai/install.ps1' | Invoke-Expression
        Write-Log 'Claude Code 安裝完成(技師連線後以 ANTHROPIC_API_KEY 或 setup-token 驗證)。' 'OK'
        # Record that WE installed Claude, and into which profile, so cleanup removes only our
        # install (scoped to this profile) and never touches a customer's pre-existing Claude data.
        Set-State @{ ClaudeInstalledByUs = $true; ClaudeInstallProfile = $env:USERPROFILE }
    }
    catch {
        Write-Log "Claude Code 安裝失敗(非致命):$($_.Exception.Message)" 'WARN'
    }
}

function Register-SelfDestruct {
    param($Config)
    $hours = [int](Get-ConfigValue $Config 'SelfDestructHours' 0)
    if ($hours -le 0) {
        Write-Log '未設定自毀排程(SelfDestructHours=0)。'
        return
    }
    # 把 cleanup.ps1 複製到穩定位置,避免安裝資料夾被刪後排程失效
    $src = Join-Path $PSScriptRoot 'cleanup.ps1'
    $cleanup = Join-Path $Script:LogDir 'cleanup.ps1'
    if (Test-Path $src) { Copy-Item $src $cleanup -Force }
    if (-not (Test-Path $cleanup)) { Write-Log '找不到 cleanup.ps1,略過自毀排程註冊。' 'WARN'; return }

    $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$cleanup`" -Silent"
    $trigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddHours($hours))
    Register-ScheduledTask -TaskName 'AIRemoteRepair-SelfDestruct' -Action $action -Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
    Write-Log "已註冊自毀排程:$hours 小時後自動執行 cleanup.ps1。" 'OK'
}

function Test-Installation {
    param($Config)
    Write-Log '步驟 6/6:驗證安裝結果' 'STEP'
    $ok = $true

    $svc = Get-Service sshd -ErrorAction SilentlyContinue
    if ($svc -and $svc.Status -eq 'Running') { Write-Log '  [v] sshd 服務執行中' 'OK' }
    else { Write-Log '  [x] sshd 服務未執行' 'ERROR'; $ok = $false }

    if (Test-Path $AdminKeysFile) {
        $acl = (Invoke-Native 'icacls.exe' @("$AdminKeysFile")).Output -join "`n"
        Write-Log "  [i] administrators_authorized_keys ACL:`n$acl"
    }

    if ((Get-ConfigBool $Config 'RestrictToTailscaleOnly' $true)) {
        if (Get-NetFirewallRule -Name 'sshd-tailscale-only' -ErrorAction SilentlyContinue) { Write-Log '  [v] 防火牆 Tailscale-only 規則存在' 'OK' }
        else { Write-Log '  [x] 缺少防火牆鎖定規則' 'ERROR'; $ok = $false }
    }

    # 本機 22 埠監聽測試
    try {
        $client = New-Object System.Net.Sockets.TcpClient
        $client.Connect('127.0.0.1', 22); $client.Close()
        Write-Log '  [v] 本機 22 埠可連線' 'OK'
    }
    catch { Write-Log '  [x] 本機 22 埠無法連線' 'ERROR'; $ok = $false }

    return $ok
}

# ───────────────────────────────────────── 主流程 ─────────────────────────────────────────
try {
    Assert-Admin
    Write-Log '═══════════════ AI 遠程維修工具 安裝開始 ═══════════════' 'STEP'
    Write-Log "電腦:$env:COMPUTERNAME  使用者:$env:USERNAME  OS:$([Environment]::OSVersion.Version)"

    $Config = Get-Config
    $Script:ExpectedOpenSSHSha256        = (Get-ConfigValue $Config 'ExpectedOpenSSHSha256')
    $Script:ExpectedTailscaleSha256      = (Get-ConfigValue $Config 'ExpectedTailscaleSha256')
    $Script:ExpectedTailscaleArm64Sha256 = (Get-ConfigValue $Config 'ExpectedTailscaleArm64Sha256')
    $Script:ExpectedTailscaleX86Sha256   = (Get-ConfigValue $Config 'ExpectedTailscaleX86Sha256')
    $Script:AllowUnverifiedDownload      = (Get-ConfigBool $Config 'AllowUnverifiedDownload' $false)
    Write-Log "案件編號:$(Get-ConfigValue $Config 'JobId' 'n/a')"

    $showConsent = (Get-ConfigBool $Config 'ShowConsentPrompt' $true)
    if ($showConsent -and -not $Silent) {
        if (-not (Show-Consent $Config)) { exit 2 }
    }
    else {
        $authRef = Get-ConfigValue $Config 'AuthorizationRef'
        if ($authRef) { Write-Log "已略過同意畫面;授權依據:$authRef(Silent/ShowConsentPrompt=false)。" 'WARN' }
        else { Write-Log '已略過同意畫面,且未提供 AuthorizationRef。請確保已另行取得客戶明確授權。' 'WARN' }
    }

    $key = $null
    if (-not $NoTailscale) {
        $key = Resolve-AuthKey $Config
    }

    # 安裝順序刻意如此:先裝 OpenSSH,但「啟動 sshd 監聽之前」就先鎖好防火牆,
    # 確保 22 埠從一開始就只對 Tailscale 開放,杜絕安裝過程中的裸露窗口。
    Install-OpenSSH $Config
    Set-FirewallLockdown $Config
    Start-Sshd
    Configure-Sshd $Config
    $acctInfo = New-RepairAccount $Config    # 建立但停用

    $tsInfo = $null
    if ($NoTailscale) {
        Write-Log '已指定 -NoTailscale,跳過 Tailscale 安裝。' 'WARN'
    }
    else {
        $tsInfo = Install-Tailscale $Config $key
    }

    Enable-Auditing
    Install-ClaudeCodeOptional $Config
    Register-SelfDestruct $Config

    # 最後一步:確認 Tailscale 已連線(或明確 -NoTailscale)後,才啟用維修帳號。
    if ($NoTailscale -or $Script:TailscaleUp) {
        Enable-RepairAccount $Config $acctInfo.Account
    }
    else {
        Write-Log 'Tailscale 未確認連線,維修帳號維持停用以策安全。' 'WARN'
    }

    $verifyOk = Test-Installation $Config

    # 連線資訊(給技師;不含密碼)
    $info = [ordered]@{
        JobId       = (Get-ConfigValue $Config 'JobId' 'n/a')
        Computer    = $env:COMPUTERNAME
        Account     = $acctInfo.Account
        TailscaleIP = if ($tsInfo) { $tsInfo.IP } else { $null }
        FQDN        = if ($tsInfo) { $tsInfo.FQDN } else { $null }
        InstalledAt = (Get-Date).ToString('s')
        Verified    = $verifyOk
    }
    $info | ConvertTo-Json | Set-Content -Path $Script:InfoFile -Encoding utf8

    # 上報廠商(可選)
    $reportUrl = Get-ConfigValue $Config 'ReportUrl'
    if ($reportUrl -and $tsInfo) {
        try {
            Invoke-RestMethod -Uri $reportUrl -Method Post -Body ($info | ConvertTo-Json) -ContentType 'application/json' -TimeoutSec 20 | Out-Null
            Write-Log '已將連線資訊上報廠商。' 'OK'
        }
        catch { Write-Log "上報失敗(非致命):$($_.Exception.Message)" 'WARN' }
    }

    # 複製連線資訊到剪貼簿:客戶不用手動謄寫 IP,安裝完直接貼給維修人員(LINE/Email/簡訊皆可)。
    # 刻意不含密碼(密碼只在主控台顯示一次);GUI 安裝(-Silent)與此腳本共用同一個互動桌面,
    # 即便主控台視窗被隱藏,剪貼簿仍可正常寫入。失敗非致命(例如無互動桌面時)。
    $Script:ClipboardCopied = $false
    if ($tsInfo) {
        $clipText = @"
遠端維修連線資訊
案件:$($info.JobId)
電腦:$($info.Computer)
帳號:$($info.Account)
Tailscale IP:$($info.TailscaleIP)
主機名稱:$($info.FQDN)
"@
        try {
            Set-Clipboard -Value $clipText
            $Script:ClipboardCopied = $true
            Write-Log '已將連線資訊複製到剪貼簿。' 'OK'
        }
        catch { Write-Log "複製連線資訊到剪貼簿失敗(非致命):$($_.Exception.Message)" 'WARN' }
    }

    Write-Host ''
    Write-Host '════════════════════════════════════════════════════════════════' -ForegroundColor Green
    Write-Host '  安裝完成!' -ForegroundColor Green
    Write-Host '════════════════════════════════════════════════════════════════' -ForegroundColor Green
    Write-Host ''
    $acctEnabled = [bool](Get-LocalUser -Name $acctInfo.Account -ErrorAction SilentlyContinue).Enabled
    $acctState = if ($acctEnabled) { '已啟用,可登入' } else { '停用中 — 需在客戶端執行 Set-RepairSession.ps1 -Enable' }
    Write-Host "  維修帳號     : $($acctInfo.Account)"
    Write-Host "  帳號狀態     : $acctState"
    if ($tsInfo) {
        Write-Host "  Tailscale IP : $($tsInfo.IP)"
        Write-Host "  主機名稱     : $($tsInfo.FQDN)"
        Write-Host ''
        Write-Host '  技師連線方式(在你的電腦,已登入同一個 tailnet):' -ForegroundColor Cyan
        Write-Host "    ssh $($acctInfo.Account)@$($tsInfo.IP)" -ForegroundColor White
        Write-Host '    或使用 technician\connect.ps1 自動列出裝置並連線' -ForegroundColor Gray
        Write-Host ''
        if ($Script:ClipboardCopied) {
            Write-Host '  連線資訊已複製到剪貼簿,請直接貼給維修人員(LINE/Email/簡訊皆可)。' -ForegroundColor Cyan
        }
    }
    Write-Host ''
    # 密碼只在此主控台顯示一次;不會寫進 install.log、state.json 或 connection-info.json。
    Write-Host '  維修帳號密碼(請交給維修廠商,妥善保管):' -ForegroundColor Yellow
    Write-Host "    $($acctInfo.Password)" -ForegroundColor Yellow
    Write-Host ''
    Write-Host "  紀錄檔:$Script:LogFile" -ForegroundColor Gray
    Write-Host ''

    Write-Log '═══════════════ 安裝結束 ═══════════════' 'STEP'
    if (-not $verifyOk) { Write-Log '部分驗證項目未通過,請查看上方紀錄。' 'WARN'; exit 1 }
    exit 0
}
catch {
    Write-Log "安裝失敗:$($_.Exception.Message)" 'ERROR'
    Write-Log $_.ScriptStackTrace 'ERROR'

    # 失效安全:絕不留下「可登入的後門」。帳號是存取的閘門,停用它即可阻斷登入;
    # 若 Tailscale 尚未確認連線,額外移除任何來源的 SSH 規則並停止 sshd。
    try {
        if ($Script:CreatedAccount) {
            Disable-LocalUser -Name $Script:CreatedAccount -ErrorAction SilentlyContinue
            Write-Log "失效安全:已停用維修帳號 $Script:CreatedAccount。" 'WARN'
        }
        if (-not $Script:TailscaleUp) {
            Get-NetFirewallRule -Direction Inbound -Action Allow -ErrorAction SilentlyContinue |
                Where-Object { $_.DisplayName -match 'OpenSSH' -or $_.Name -match 'OpenSSH' } |
                ForEach-Object { Remove-NetFirewallRule -Name $_.Name -ErrorAction SilentlyContinue }
            Stop-Service sshd -ErrorAction SilentlyContinue
            Write-Log '失效安全:Tailscale 未連線,已停止 sshd 並移除任何來源的 SSH 規則。' 'WARN'
        }
        # burn-after-use:即使 join 失敗(或更早拋錯,單次金鑰可能尚未被消耗)也要把磁碟上的
        # auth key 清掉,避免有效金鑰以明文留存。正常成功路徑已在 Install-Tailscale 末段刪除。
        if ($PSScriptRoot) {
            $ak = Join-Path $PSScriptRoot 'authkey.txt'
            if (Test-Path $ak) { Remove-Item $ak -Force -ErrorAction SilentlyContinue; Write-Log '失效安全:已刪除磁碟上的 authkey.txt。' 'WARN' }
        }
        $kf = Join-Path $env:TEMP 'ts-authkey.txt'
        if (Test-Path $kf) { Remove-Item $kf -Force -ErrorAction SilentlyContinue }
    }
    catch { Write-Log "失效安全處理時發生例外(已忽略):$($_.Exception.Message)" 'WARN' }

    Write-Host ''
    Write-Host "安裝失敗:$($_.Exception.Message)" -ForegroundColor Red
    Write-Host '已啟動失效安全:維修帳號已停用,未連上 Tailscale 時 sshd 也已停止。' -ForegroundColor Yellow
    Write-Host "詳見紀錄檔:$Script:LogFile" -ForegroundColor Gray
    exit 1
}
