# dev script helpers (ASCII only for Windows PowerShell 5.1)

. (Join-Path $PSScriptRoot "config.ps1")

function Write-DevLog {
    param(
        [string]$Message,
        [ValidateSet("Info", "Ok", "Warn", "Err")]
        [string]$Level = "Info"
    )
    $prefix = switch ($Level) {
        "Ok"   { "[OK] " }
        "Warn" { "[WARN] " }
        "Err"  { "[ERR] " }
        default { "[..] " }
    }
    Write-Host "$prefix$Message"
}

function Invoke-DevNativeCommand {
    param(
        [Parameter(Mandatory = $true)]
        [scriptblock]$CommandBlock
    )
    $prevEap = $ErrorActionPreference
    $ErrorActionPreference = "Continue"
    try {
        $lines = & $CommandBlock 2>&1
        $textLines = @()
        foreach ($line in $lines) {
            $text = if ($line -is [System.Management.Automation.ErrorRecord]) {
                $line.ToString()
            }
            else {
                [string]$line
            }
            $textLines += $text
            Write-Host $text
        }
        $script:LastDevNativeOutput = ($textLines -join [Environment]::NewLine)
        return $LASTEXITCODE
    }
    finally {
        $ErrorActionPreference = $prevEap
    }
}

function Invoke-NpmScript {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ScriptName,
        [Parameter(Mandatory = $true)]
        [string]$FailMessage
    )
    $code = Invoke-DevNativeCommand { npm run $ScriptName }
    if ($code -ne 0) {
        throw $FailMessage
    }
}

function Ensure-DevDirs {
    foreach ($dir in @($script:DevConfig.DevDir, $script:DevConfig.PidDir, $script:DevConfig.LogDir)) {
        if (-not (Test-Path $dir)) {
            New-Item -ItemType Directory -Path $dir -Force | Out-Null
        }
    }
}

function Stop-PortListeners {
    param([int[]]$Ports)
    foreach ($port in $Ports) {
        $killed = @()
        try {
            $conns = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
            foreach ($conn in $conns) {
                $processId = $conn.OwningProcess
                if ($processId -and $processId -gt 0 -and $killed -notcontains $processId) {
                    Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
                    $killed += $processId
                    Write-DevLog "freed port $port pid=$processId" "Ok"
                }
            }
        }
        catch {
            $lines = netstat -ano | Select-String ":$port\s"
            foreach ($line in $lines) {
                if ($line -match '\s+(\d+)\s*$') {
                    $processId = [int]$Matches[1]
                    if ($processId -gt 0 -and $killed -notcontains $processId) {
                        Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
                        $killed += $processId
                        Write-DevLog "freed port $port pid=$processId (netstat)" "Ok"
                    }
                }
            }
        }
    }
}

function Stop-PidFileProcesses {
    Ensure-DevDirs
    $pidFiles = Get-ChildItem -Path $script:DevConfig.PidDir -Filter "*.pid" -ErrorAction SilentlyContinue
    foreach ($file in $pidFiles) {
        $raw = Get-Content $file.FullName -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($raw -match '^\d+$') {
            $procId = [int]$raw
            Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
            Write-DevLog "stopped $($file.BaseName) pid=$procId" "Ok"
        }
        Remove-Item $file.FullName -Force -ErrorAction SilentlyContinue
    }
}

function Get-ProjectNodeProcessIds {
    param([string]$Root)
    $escaped = [regex]::Escape($Root)
    $ids = @()
    try {
        Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue |
            Where-Object { $_.CommandLine -and $_.CommandLine -match $escaped } |
            ForEach-Object { $ids += $_.ProcessId }
    }
    catch {
        Write-DevLog "skip node query by path (no WMI)" "Warn"
    }
    return @($ids | Select-Object -Unique)
}

function Stop-ProjectNodeProcesses {
    param([string]$Root)
    foreach ($procId in (Get-ProjectNodeProcessIds -Root $Root)) {
        Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
        Write-DevLog "stopped node pid=$procId" "Ok"
    }
}

function Wait-DevProjectProcessesGone {
    param(
        [string]$Root,
        [int]$TimeoutSec = 20
    )
    $deadline = (Get-Date).AddSeconds($TimeoutSec)
    while ((Get-Date) -lt $deadline) {
        $remaining = Get-ProjectNodeProcessIds -Root $Root
        if ($remaining.Count -eq 0) {
            return $true
        }
        foreach ($procId in $remaining) {
            Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
            Write-DevLog "re-stop node pid=$procId" "Warn"
        }
        Start-Sleep -Milliseconds 800
    }

    $left = Get-ProjectNodeProcessIds -Root $Root
    if ($left.Count -gt 0) {
        Write-DevLog "dev processes still running pid=$($left -join ',')" "Warn"
        return $false
    }
    return $true
}

function Test-PrismaEngineWritable {
    param([string]$Root)
    $engine = Join-Path $Root "node_modules\.prisma\client\query_engine-windows.dll.node"
    if (-not (Test-Path $engine)) {
        return $true
    }
    try {
        $stream = [System.IO.File]::Open(
            $engine,
            [System.IO.FileMode]::Open,
            [System.IO.FileAccess]::ReadWrite,
            [System.IO.FileShare]::None
        )
        $stream.Close()
        return $true
    }
    catch {
        return $false
    }
}

function Wait-PrismaEngineWritable {
    param(
        [string]$Root,
        [int]$TimeoutSec = 25
    )
    $deadline = (Get-Date).AddSeconds($TimeoutSec)
    while ((Get-Date) -lt $deadline) {
        if (Test-PrismaEngineWritable -Root $Root) {
            return $true
        }
        Start-Sleep -Milliseconds 600
    }
    return $false
}

function Clear-PrismaEngineTempFiles {
    param([string]$Root)
    $clientDir = Join-Path $Root "node_modules\.prisma\client"
    if (-not (Test-Path $clientDir)) {
        return
    }
    Get-ChildItem -Path $clientDir -Filter "query_engine*.tmp*" -ErrorAction SilentlyContinue |
        ForEach-Object {
            Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
            Write-DevLog "removed stale prisma tmp: $($_.Name)" "Ok"
        }
}

function Invoke-PrismaGenerateSafe {
    param(
        [string]$Root,
        [int]$MaxAttempts = 5
    )
    Clear-PrismaEngineTempFiles -Root $Root

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        if ($attempt -gt 1) {
            Stop-AllDevProcesses
        }

        if (-not (Wait-DevProjectProcessesGone -Root $Root -TimeoutSec 15)) {
            throw "dev processes still hold Prisma engine (run npm run dev:stop first)"
        }

        if (-not (Wait-PrismaEngineWritable -Root $Root -TimeoutSec 12)) {
            Write-DevLog "query_engine DLL locked, retry $($attempt) of $($MaxAttempts)..." "Warn"
            Start-Sleep -Seconds ([Math]::Min($attempt * 2, 10))
            continue
        }

        Push-Location $Root
        try {
            $exitCode = Invoke-DevNativeCommand { npx prisma generate }
            if ($exitCode -eq 0) {
                return
            }
            $output = $script:LastDevNativeOutput
        }
        finally {
            Pop-Location
        }

        if ($output -notmatch 'EPERM|operation not permitted|EBUSY|being used by another process') {
            throw "prisma generate failed"
        }

        Write-DevLog "prisma generate EPERM, retry $($attempt) of $($MaxAttempts)..." "Warn"
        Start-Sleep -Seconds ([Math]::Min($attempt * 2, 10))
    }

    throw "prisma generate failed: query_engine DLL locked (run npm run dev:stop first)"
}

function Clear-DevCaches {
    param([string]$Root)
    $targets = @(
        (Join-Path $Root ".next"),
        (Join-Path $Root "tsconfig.tsbuildinfo"),
        (Join-Path $Root "node_modules/.cache"),
        (Join-Path $Root "node_modules/.vite")
    )
    foreach ($path in $targets) {
        if (Test-Path $path) {
            Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
            Write-DevLog "removed cache: $path" "Ok"
        }
    }
}

function Test-TcpPortQuick {
    param(
        [string]$HostName,
        [int]$Port,
        [int]$TimeoutMs = 800
    )
    $client = $null
    try {
        $client = New-Object System.Net.Sockets.TcpClient
        $connect = $client.BeginConnect($HostName, $Port, $null, $null)
        $ok = $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
        if (-not $ok) {
            return $false
        }
        $client.EndConnect($connect)
        return $true
    }
    catch {
        return $false
    }
    finally {
        if ($client) {
            $client.Close()
        }
    }
}

function Import-DevEnvFiles {
    param([string]$Root)
    foreach ($file in @(
            (Join-Path $Root ".env"),
            (Join-Path $Root ".dev/infra.env")
        )) {
        if (-not (Test-Path $file)) { continue }
        Get-Content $file | ForEach-Object {
            if ($_ -match '^\s*(#|$)') { return }
            $idx = $_.IndexOf('=')
            if ($idx -gt 0) {
                $k = $_.Substring(0, $idx).Trim()
                $v = $_.Substring($idx + 1)
                Set-Item -Path "env:$k" -Value $v
            }
        }
    }
}

function Test-RedisReachableQuick {
    param([int]$TimeoutMs = 800)
    $url = $env:REDIS_URL
    if (-not $url) { return $false }
    if ($url -match 'redis://([^:/]+):(\d+)') {
        $hostName = $Matches[1]
        if ($hostName -eq "localhost") { $hostName = "127.0.0.1" }
        return Test-TcpPortQuick -HostName $hostName -Port ([int]$Matches[2]) -TimeoutMs $TimeoutMs
    }
    return $false
}

function Wake-WslRedisForward {
    if ($env:OS -notmatch "Windows") { return }
    $distro = Get-DevWslDistro
    if (-not $distro) { return }
    $prev = $ErrorActionPreference
    $ErrorActionPreference = "SilentlyContinue"
    wsl.exe -d $distro -e redis-cli ping 2>&1 | Out-Null
    $ErrorActionPreference = $prev
}

function Invoke-FlushRedis {
    param([string]$Root)
    Push-Location $Root
    try {
        Import-DevEnvFiles -Root $Root
        if (-not (Test-RedisReachableQuick -TimeoutMs 800)) {
            Wake-WslRedisForward
            if (-not (Test-RedisReachableQuick -TimeoutMs 1200)) {
                Write-DevLog "redis flush skipped (port closed, <2s)" "Warn"
                return
            }
        }

        $prev = $ErrorActionPreference
        $ErrorActionPreference = "Continue"
        npx tsx scripts/dev/flush-redis.ts *> $null
        $code = $LASTEXITCODE
        $ErrorActionPreference = $prev
        if ($code -ne 0) {
            Write-DevLog "redis flush skipped (redis not up?)" "Warn"
        }
        else {
            Write-DevLog "redis flushed" "Ok"
        }
    }
    catch {
        Write-DevLog "redis flush skipped (redis not up?)" "Warn"
    }
    finally {
        Pop-Location
    }
}

function Wait-HttpOk {
    param(
        [string]$Url,
        [int]$TimeoutSec = 120
    )
    $deadline = (Get-Date).AddSeconds($TimeoutSec)
    while ((Get-Date) -lt $deadline) {
        try {
            $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5
            if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 500) {
                Write-DevLog "$Url OK (HTTP $($resp.StatusCode))" "Ok"
                return $true
            }
        }
        catch {
            Start-Sleep -Seconds 2
        }
    }
    throw "timeout waiting for $Url (${TimeoutSec}s)"
}

function Start-DevBackgroundProcess {
    param(
        [string]$Name,
        [string]$Command,
        [string]$Root
    )
    Ensure-DevDirs
    $logFile = Join-Path $script:DevConfig.LogDir "$Name.log"
    $pidFile = Join-Path $script:DevConfig.PidDir "$Name.pid"
    $rootEsc = $Root.Replace("'", "''")
    $logEsc = $logFile.Replace("'", "''")
    $infraEnv = Join-Path $Root ".dev/infra.env"
    $rootEnv = Join-Path $Root ".env"
    $envLoad = ""
    if (Test-Path $rootEnv) {
        $rootEnvEsc = $rootEnv.Replace("'", "''")
        $envLoad += @"
Get-Content '$rootEnvEsc' | ForEach-Object {
    if (`$_ -match '^\s*(#|$)') { return }
    `$idx = `$_.IndexOf('=')
    if (`$idx -gt 0) {
        `$k = `$_.Substring(0, `$idx).Trim()
        `$v = `$_.Substring(`$idx + 1)
        Set-Item -Path env:`$k -Value `$v
    }
};
"@
    }
    if (Test-Path $infraEnv) {
        $infraEsc = $infraEnv.Replace("'", "''")
        $envLoad += @"
Get-Content '$infraEsc' | ForEach-Object {
    if (`$_ -match '^\s*(#|$)') { return }
    `$idx = `$_.IndexOf('=')
    if (`$idx -gt 0) {
        `$k = `$_.Substring(0, `$idx).Trim()
        `$v = `$_.Substring(`$idx + 1)
        Set-Item -Path env:`$k -Value `$v
    }
};
"@
    }

    # 新进程启动时清空旧日志，避免健康检查误读历史 fail-fast
    Set-Content -Path $logFile -Value "" -Encoding utf8

    $psCommand = "${envLoad}Set-Location '$rootEsc'; `$ErrorActionPreference='Continue'; $Command *>&1 | ForEach-Object { `"`$_`" | Out-File -LiteralPath '$logEsc' -Append -Encoding utf8 }"
    $proc = Start-Process -FilePath "powershell.exe" `
        -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", $psCommand `
        -PassThru `
        -WindowStyle Hidden

    Set-Content -Path $pidFile -Value $proc.Id -Encoding ascii
    Write-DevLog "started $Name pid=$($proc.Id) log=$logFile" "Ok"
    return $proc.Id
}

function Get-DevLogLatestRunText {
    param([string]$LogFile)
    if (-not (Test-Path $LogFile)) {
        return ""
    }
    $lines = Get-Content $LogFile -ErrorAction SilentlyContinue
    if (-not $lines) {
        return ""
    }
    $startIdx = 0
    for ($i = $lines.Count - 1; $i -ge 0; $i--) {
        if ($lines[$i] -match '^> chajia@') {
            $startIdx = $i
            break
        }
    }
    return ($lines[$startIdx..($lines.Count - 1)] -join "`n")
}

function Assert-DevWorkerHealthy {
    param(
        [string]$Name,
        [int]$WaitSeconds = 3
    )
    Ensure-DevDirs
    Start-Sleep -Seconds $WaitSeconds

    $pidFile = Join-Path $script:DevConfig.PidDir "$Name.pid"
    $logFile = Join-Path $script:DevConfig.LogDir "$Name.log"
    $latest = Get-DevLogLatestRunText -LogFile $logFile

    if (Test-Path $pidFile) {
        $workerPid = [int](Get-Content $pidFile -ErrorAction SilentlyContinue)
        $proc = Get-Process -Id $workerPid -ErrorAction SilentlyContinue
        if (-not $proc) {
            $hint = Get-DevWorkerFailureHint -LogFile $logFile -RunText $latest
            throw "$Name process exited. $hint Log: $logFile"
        }
    }

    if (-not $latest) {
        return
    }

    if ($latest -match "MOTHERSHIP_QUOTE_URLS|缺少 RPA selector|assertRpaWorkerEnv|启动失败") {
        $hint = Get-DevWorkerFailureHint -LogFile $logFile -RunText $latest
        throw "$Name failed to start. $hint Log: $logFile"
    }
}

function Get-DevWorkerFailureHint {
    param(
        [string]$LogFile,
        [string]$RunText = ""
    )
    if (-not $RunText -and (Test-Path $LogFile)) {
        $RunText = Get-DevLogLatestRunText -LogFile $LogFile
    }
    if (-not $RunText) {
        return "Configure RPA env per docs/rpa-recording-sop.md."
    }
    if ($RunText -match "MOTHERSHIP_QUOTE_URLS") {
        return "Set MOTHERSHIP_QUOTE_URLS (headed recording required, see docs/rpa-recording-sop.md)."
    }
    if ($RunText -match "缺少 RPA selector env:\s*RPA_SELECTOR_FASTEST_OPTION\s*$") {
        return "Update code: RPA_SELECTOR_FASTEST_OPTION is optional; run git pull or rebuild."
    }
    if ($RunText -match "缺少 RPA selector env") {
        return "Set required RPA_SELECTOR_* from Playwright recording (FASTEST optional)."
    }
    return "Check RPA env (MOTHERSHIP_QUOTE_URLS + RPA_SELECTOR_*)."
}

function Get-DevWslDistro {
    $prev = $ErrorActionPreference
    $ErrorActionPreference = "SilentlyContinue"
    $raw = & wsl.exe -l -q 2>&1 | Out-String
    $ErrorActionPreference = $prev

    # wsl -l -q emits UTF-16LE; strip null bytes for PowerShell 5.1
    $text = $raw -replace "`0", ""

    $distros = @()
    foreach ($line in ($text -split "`r?`n")) {
        $name = ($line -replace '^\*\s*', '').Trim()
        if ($name -and $name -notmatch '^(docker-desktop|docker-desktop-data)$') {
            $distros += $name
        }
    }

    $ubuntu = $distros | Where-Object { $_ -match 'Ubuntu' } | Select-Object -First 1
    if ($ubuntu) { return $ubuntu }
    if ($distros.Count -gt 0) { return $distros[0] }
    return $null
}

function Ensure-DevWslDistro {
    $distro = Get-DevWslDistro
    if ($distro) { return $distro }

    Write-DevLog "no Linux WSL distro, installing Ubuntu-22.04 (one-time)..." "Warn"
    $prev = $ErrorActionPreference
    $ErrorActionPreference = "SilentlyContinue"
    wsl --install -d Ubuntu-22.04 --no-launch 2>&1 | Out-Null
    $ErrorActionPreference = $prev

    $distro = Get-DevWslDistro
    if (-not $distro) {
        throw "WSL Linux distro required. Run: wsl --install -d Ubuntu-22.04"
    }
    return $distro
}

function Invoke-DevWsl {
    param([string]$BashCommand)

    $distro = Ensure-DevWslDistro
    $prev = $ErrorActionPreference
    $ErrorActionPreference = "SilentlyContinue"
    wsl -d $distro -e bash -lc $BashCommand 2>&1 | Out-Null
    $code = $LASTEXITCODE
    $ErrorActionPreference = $prev
    return $code
}

function Stop-AllDevProcesses {
    Write-DevLog "stopping dev processes..."
    Stop-PidFileProcesses
    Stop-WslKeepAlive
    $ports = @($script:DevConfig.NextPort) + $script:DevConfig.ExtraPorts
    Stop-PortListeners -Ports $ports
    Stop-ProjectNodeProcesses -Root $script:DevConfig.ProjectRoot
    $null = Wait-DevProjectProcessesGone -Root $script:DevConfig.ProjectRoot -TimeoutSec 12
    Start-Sleep -Milliseconds 800
}

function Start-WslKeepAlive {
    param([string]$Distro)
    Ensure-DevDirs
    $pidFile = Join-Path $script:DevConfig.PidDir "wsl-keepalive.pid"
    if (Test-Path $pidFile) {
        $oldPid = [int](Get-Content $pidFile -ErrorAction SilentlyContinue)
        if ($oldPid -gt 0 -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) {
            return
        }
    }
    $proc = Start-Process -FilePath "wsl.exe" `
        -ArgumentList "-d", $Distro, "-e", "sleep", "infinity" `
        -WindowStyle Hidden -PassThru
    Set-Content -Path $pidFile -Value $proc.Id -Encoding ascii
    Write-DevLog "wsl keepalive pid=$($proc.Id)" "Ok"
}

function Stop-WslKeepAlive {
    $pidFile = Join-Path $script:DevConfig.PidDir "wsl-keepalive.pid"
    if (-not (Test-Path $pidFile)) {
        return
    }
    $keepPid = [int](Get-Content $pidFile -ErrorAction SilentlyContinue)
    if ($keepPid -gt 0) {
        Stop-Process -Id $keepPid -Force -ErrorAction SilentlyContinue
    }
    Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
}

# Resolve LAN IPv4 for colleague testing (skip WSL/Hyper-V virtual adapters).
function Resolve-DevLanHost {
    param(
        [string]$Configured = "auto"
    )
    if ($Configured -and $Configured -ne "auto") {
        return $Configured.Trim()
    }

    $virtualPattern = 'WSL|Hyper-V|vEthernet|Docker|VMware|Loopback|Teredo|isatap|Npcap|VirtualBox'
    $addrs = @()
    try {
        $addrs = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
            Where-Object {
                $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
                $_.InterfaceAlias -notmatch $virtualPattern
            })
    }
    catch {
        return $null
    }

    if ($addrs.Count -eq 0) {
        return $null
    }

    $preferred = @($addrs | Where-Object {
            $_.InterfaceAlias -match 'WLAN|Wi-?Fi|Wireless|Ethernet|以太网|无线'
        } | Sort-Object InterfaceMetric)[0]
    if ($preferred) {
        return $preferred.IPAddress
    }

    return ($addrs | Sort-Object InterfaceMetric)[0].IPAddress
}

# 内网测试：允许同事访问本机 Next 端口（需管理员权限时仅告警）
function Ensure-DevLanFirewall {
    $port = $script:DevConfig.NextPort
    $ruleName = "chajia-dev-tcp-$port"
    $existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
    if ($existing) {
        return
    }
    try {
        New-NetFirewallRule `
            -DisplayName $ruleName `
            -Direction Inbound `
            -LocalPort $port `
            -Protocol TCP `
            -Profile Any `
            -Action Allow `
            -ErrorAction Stop | Out-Null
        Write-DevLog "firewall inbound TCP $port allowed ($ruleName)" "Ok"
    }
    catch {
        Write-DevLog "firewall: could not open TCP $port (run dev:start as Administrator once)" "Warn"
    }
}

. (Join-Path $PSScriptRoot "ensure-infra.ps1")
