param(
  [string]$BaseUrl = "http://127.0.0.1:17317",
  [int[]]$ProbeIndices = @(1, 3),
  [int[]]$Limits = @(2, 5, 10, 20),
  [int]$PollIntervalSeconds = 2,
  [int]$TimeoutSeconds = 600,
  [string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\live-soak"),
  [switch]$GoalRun,
  [switch]$RepeatabilityRun,
  [ValidateSet("current")]
  [string]$ScanEngine = "current",
  [switch]$BenchmarkOcr,
  [int]$BenchmarkLimit = 5,
  [ValidateSet("current")]
  [string]$BenchmarkEngine = "current",
  [ValidateSet("fast", "full")]
  [string]$BenchmarkProfile = "fast",
  [switch]$SkipSmartCapture,
  [switch]$SaveFullReviewSamples,
  [switch]$AllowStaleBuild,
  [string]$ExpectedAppSignature,
  [switch]$ContinueAfterBlocked,
  [switch]$SelfTestAssessment
)

$ErrorActionPreference = "Stop"

if ([string]::IsNullOrWhiteSpace($ExpectedAppSignature)) {
  $mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
  if (Test-Path -LiteralPath $mainPath) {
    $mainSource = Get-Content -LiteralPath $mainPath -Raw
    $match = [regex]::Match($mainSource, 'APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"')
    if ($match.Success) {
      $ExpectedAppSignature = $match.Groups[1].Value
    }
  }
}

function ConvertTo-SafeFilePart([string]$Value) {
  $safe = $Value -replace "[^A-Za-z0-9._-]+", "-"
  $safe = $safe.Trim("-")
  if ($safe.Length -eq 0) { return "item" }
  if ($safe.Length -gt 80) { return $safe.Substring(0, 80) }
  return $safe
}

function Invoke-DevJson([string]$Path) {
  $uri = if ($Path.StartsWith("http")) { $Path } else { "$BaseUrl$Path" }
  Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 60
}

function Save-Json([string]$Name, [object]$Payload) {
  $path = Join-Path $RunDir "$(ConvertTo-SafeFilePart $Name).json"
  $Payload | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $path -Encoding UTF8
  return $path
}

function Assert-CurrentAppBuild([object]$HealthPayload) {
  if ($AllowStaleBuild) { return }

  if ($null -eq $HealthPayload.appBuild) {
    throw "Dev endpoint is stale: /health has no appBuild signature. Close the old elevated Genshin Artifact Assistant/Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
  }

  if ([string]::IsNullOrWhiteSpace([string]$HealthPayload.appBuild.signature)) {
    throw "Dev endpoint is stale: appBuild.signature is empty. Restart the elevated app before live scanner timing."
  }

  if (-not [string]::IsNullOrWhiteSpace($ExpectedAppSignature) -and [string]$HealthPayload.appBuild.signature -ne $ExpectedAppSignature) {
    throw "Dev endpoint is stale: appBuild.signature='$($HealthPayload.appBuild.signature)' but current source expects '$ExpectedAppSignature'. Close the old Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
  }
}

function Get-ScannerStatus {
  Invoke-DevJson "/scanner/status"
}

function Test-ProbeSucceeded([object]$ProbePayload) {
  if ($ProbePayload.ok) { return $true }
  if ($ProbePayload.changed) { return $true }
  if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
  return $false
}

function Get-CompletedScanSummary([object]$StatusPayload, [int]$Limit) {
  if ($null -ne $StatusPayload.status.summary) {
    return $StatusPayload.status.summary
  }

  $reviewStatus = [string]$StatusPayload.status.reviewStatus
  $status = "done"
  if ($reviewStatus -match "blockiert") {
    $status = "blocked"
  } elseif ($reviewStatus -match "gestoppt") {
    $status = "stopped"
  }

  $stats = $StatusPayload.status.stats
  return [pscustomobject]@{
    mode = "Automatischer Scan"
    status = $status
    clicked = [int]$stats.clicked
    attempted = [int]$stats.attempted
    verified = [int]$stats.verified
    parsed = [int]$stats.parsed
    stored = [int]$stats.stored
    review = [int]$stats.review
    duplicates = [int]$stats.duplicates
    misses = [int]$stats.misses
    pages = [int]$stats.pages
    targetCount = $Limit
    gridLabel = $reviewStatus
  }
}

function Convert-ScanTimingSummary([object]$StatusPayload, [int]$Limit, [string]$Engine) {
  $summary = Get-CompletedScanSummary $StatusPayload $Limit
  $stats = $StatusPayload.status.stats
  return [pscustomobject]@{
    engine = $Engine
    limit = $Limit
    status = $summary.status
    clicked = $summary.clicked
    attempted = $summary.attempted
    verified = $summary.verified
    parsed = $summary.parsed
    stored = $summary.stored
    review = $summary.review
    duplicates = $summary.duplicates
    misses = $summary.misses
    pages = $summary.pages
    elapsedMs = [int]$stats.elapsedMs
    activeScanMs = [int]$stats.activeScanMs
    writeFlushMs = [int]$stats.writeFlushMs
    averageMsPerParsed = [int]$stats.averageMsPerParsed
    activeAverageMsPerParsed = [int]$stats.activeAverageMsPerParsed
    artifactsPerMinute = [double]$stats.artifactsPerMinute
    activeArtifactsPerMinute = [double]$stats.activeArtifactsPerMinute
    projectedMsFor100 = [int]$stats.projectedMsFor100
    activeProjectedMsFor100 = [int]$stats.activeProjectedMsFor100
    averageCaptureMs = [int]$stats.averageCaptureMs
    averageCaptureRoundTripMs = [int]$stats.averageCaptureRoundTripMs
    averageCaptureRoundTripOverheadMs = [int]$stats.averageCaptureRoundTripOverheadMs
    averageOcrMs = [int]$stats.averageOcrMs
    captureP50Ms = [int]$stats.captureP50Ms
    captureP90Ms = [int]$stats.captureP90Ms
    ocrP50Ms = [int]$stats.ocrP50Ms
    ocrP90Ms = [int]$stats.ocrP90Ms
    cardReadyMs = [int]$stats.cardReadyMs
    cardReadyCount = [int]$stats.cardReadyCount
    averageCardReadyMs = [int]$stats.averageCardReadyMs
    scrollReadyMs = [int]$stats.scrollReadyMs
    scrollReadyCount = [int]$stats.scrollReadyCount
    averageScrollReadyMs = [int]$stats.averageScrollReadyMs
    captureMs = [int]$stats.captureMs
    captureRoundTripMs = [int]$stats.captureRoundTripMs
    captureRoundTripOverheadMs = [int]$stats.captureRoundTripOverheadMs
    ocrMs = [int]$stats.ocrMs
    reviewStatus = [string]$StatusPayload.status.reviewStatus
  }
}

function Write-ScanTimingLine([object]$Timing) {
  Write-Host ("engine={0} limit={1} status={2} parsed={3} review={4} miss={5} elapsed={6}ms active={7}ms flush={8}ms avg={9}ms activeAvg={10}ms captureAvg={11}ms roundtripAvg={12}ms roundtripOverheadAvg={13}ms captureP50={14}ms captureP90={15}ms ocrAvg={16}ms ocrP50={17}ms ocrP90={18}ms cardReadyAvg={19}ms scrollReadyAvg={20}ms ppm={21} activePpm={22} projected100={23}ms activeProjected100={24}ms" -f `
    $Timing.engine,
    $Timing.limit,
    $Timing.status,
    $Timing.parsed,
    $Timing.review,
    $Timing.misses,
    $Timing.elapsedMs,
    $Timing.activeScanMs,
    $Timing.writeFlushMs,
    $Timing.averageMsPerParsed,
    $Timing.activeAverageMsPerParsed,
    $Timing.averageCaptureMs,
    $Timing.averageCaptureRoundTripMs,
    $Timing.averageCaptureRoundTripOverheadMs,
    $Timing.captureP50Ms,
    $Timing.captureP90Ms,
    $Timing.averageOcrMs,
    $Timing.ocrP50Ms,
    $Timing.ocrP90Ms,
    $Timing.averageCardReadyMs,
    $Timing.averageScrollReadyMs,
    $Timing.artifactsPerMinute,
    $Timing.activeArtifactsPerMinute,
    $Timing.projectedMsFor100,
    $Timing.activeProjectedMsFor100)
}

function Get-TimingBottleneck([object]$Timing) {
  $parts = @(
    [pscustomobject]@{ name = "ocr"; value = [int]$Timing.averageOcrMs },
    [pscustomobject]@{ name = "capture-roundtrip-overhead"; value = [int]$Timing.averageCaptureRoundTripOverheadMs },
    [pscustomobject]@{ name = "capture"; value = [int]$Timing.averageCaptureMs },
    [pscustomobject]@{ name = "card-ready"; value = [int]$Timing.averageCardReadyMs },
    [pscustomobject]@{ name = "scroll-ready"; value = [int]$Timing.averageScrollReadyMs }
  ) | Sort-Object -Property value -Descending

  if ($parts.Count -eq 0 -or $parts[0].value -le 0) { return "unknown" }
  return $parts[0].name
}

function Get-TimingRecommendation([object]$Timing) {
  $bottleneck = Get-TimingBottleneck $Timing
  switch ($bottleneck) {
    "ocr" { return "OCR dominates; inspect crop count, worker pool, and parser-derived fields first." }
    "capture-roundtrip-overhead" { return "Capture transport overhead dominates; inspect native encode, IPC payload size, and Base64/DataURL conversion before OCR changes." }
    "capture" { return "Capture dominates; reduce payloads/crops and avoid full-frame or Base64 work in the hot loop." }
    "card-ready" { return "Card-ready dominates; tune detail fingerprint polling before OCR changes." }
    "scroll-ready" { return "Scroll-ready dominates; tune page fingerprint polling before OCR changes." }
    default { return "No dominant timing component detected; inspect misses/review/duplicates and raw diagnostic events." }
  }
}

function Get-ScanQuality([object]$Timing) {
  $parsed = [int]$Timing.parsed
  $misses = [int]$Timing.misses
  $review = [int]$Timing.review
  $target = [int]$Timing.limit
  $processed = [Math]::Max(1, $parsed + $misses)
  $parseCoverage = if ($target -gt 0) { [Math]::Round($parsed / $target, 4) } else { 0 }
  $missRate = [Math]::Round($misses / $processed, 4)
  $reviewRate = if ($parsed -gt 0) { [Math]::Round($review / $parsed, 4) } else { 1 }
  $statusOk = [string]$Timing.status -eq "done"
  $coverageOk = $parsed -ge $target
  $missOk = $missRate -le 0.02
  $reviewOk = $reviewRate -le 0.15
  $qualityPenalty = [Math]::Round(($missRate * 1000000) + ($reviewRate * 250000) + ((1 - $parseCoverage) * 1000000), 0)
  $qualityDecision = if (-not $statusOk) {
    "not-qualified: scan did not finish cleanly"
  } elseif (-not $coverageOk) {
    "not-qualified: parsed fewer artifacts than requested"
  } elseif (-not $missOk) {
    "not-qualified: miss rate above 2%"
  } elseif (-not $reviewOk) {
    "not-qualified: review rate above 15%"
  } else {
    "qualified"
  }

  return [pscustomobject]@{
    parseCoverage = $parseCoverage
    missRate = $missRate
    reviewRate = $reviewRate
    qualityDecision = $qualityDecision
    qualityPenalty = $qualityPenalty
    qualified = ($statusOk -and $coverageOk -and $missOk -and $reviewOk)
  }
}

function New-PerformanceAssessment([object[]]$Summaries) {
  $limitReports = @()
  foreach ($group in ($Summaries | Group-Object -Property limit | Sort-Object { [int]$_.Name })) {
    $entries = @($group.Group | ForEach-Object {
      $quality = Get-ScanQuality $_
      $_ | Add-Member -NotePropertyName parseCoverage -NotePropertyValue $quality.parseCoverage -Force
      $_ | Add-Member -NotePropertyName missRate -NotePropertyValue $quality.missRate -Force
      $_ | Add-Member -NotePropertyName reviewRate -NotePropertyValue $quality.reviewRate -Force
      $_ | Add-Member -NotePropertyName qualityDecision -NotePropertyValue $quality.qualityDecision -Force
      $_ | Add-Member -NotePropertyName qualityPenalty -NotePropertyValue $quality.qualityPenalty -Force
      $_ | Add-Member -NotePropertyName qualified -NotePropertyValue $quality.qualified -Force
      $_
    } | Sort-Object -Property @{ Expression = "qualified"; Descending = $true }, qualityPenalty, activeAverageMsPerParsed, averageMsPerParsed)
    if ($entries.Count -eq 0) { continue }
    $winner = $entries[0]
    $engineReports = @()
    foreach ($entry in $entries) {
      $engineReports += [pscustomobject]@{
        engine = $entry.engine
        status = $entry.status
        parsed = $entry.parsed
        review = $entry.review
        misses = $entry.misses
        parseCoverage = $entry.parseCoverage
        missRate = $entry.missRate
        reviewRate = $entry.reviewRate
        qualified = $entry.qualified
        qualityDecision = $entry.qualityDecision
        qualityPenalty = $entry.qualityPenalty
        activeAverageMsPerParsed = $entry.activeAverageMsPerParsed
        activeProjectedMsFor100 = $entry.activeProjectedMsFor100
        averageOcrMs = $entry.averageOcrMs
        averageCaptureMs = $entry.averageCaptureMs
        averageCaptureRoundTripMs = $entry.averageCaptureRoundTripMs
        averageCaptureRoundTripOverheadMs = $entry.averageCaptureRoundTripOverheadMs
        averageCardReadyMs = $entry.averageCardReadyMs
        averageScrollReadyMs = $entry.averageScrollReadyMs
        bottleneck = Get-TimingBottleneck $entry
        recommendation = Get-TimingRecommendation $entry
      }
    }

    $limitReports += [pscustomobject]@{
      limit = [int]$group.Name
      engineCount = $entries.Count
      enginesCompared = @($entries | ForEach-Object { $_.engine })
      singleEngine = $true
      winnerEngine = $winner.engine
      winnerQualified = $winner.qualified
      winnerMissRate = $winner.missRate
      winnerReviewRate = $winner.reviewRate
      winnerActiveAverageMsPerParsed = $winner.activeAverageMsPerParsed
      winnerActiveProjectedMsFor100 = $winner.activeProjectedMsFor100
      winnerAverageCaptureRoundTripMs = $winner.averageCaptureRoundTripMs
      winnerAverageCaptureRoundTripOverheadMs = $winner.averageCaptureRoundTripOverheadMs
      engines = $engineReports
    }
  }

  $goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1)
  $goal100Decision = "not-run: missing 100-artifact assessment"
  if ($goal100.Count -gt 0) {
    if (-not $goal100[0].winnerQualified) {
      $goal100Decision = "not-qualified: 100-artifact winner failed quality gates"
    } else {
      $goal100Decision = "qualified: winner=$($goal100[0].winnerEngine)"
    }
  }
  return [pscustomobject]@{
    createdAt = (Get-Date).ToString("o")
    goalLimit = 100
    goalEngines = @("current")
    goal100Decision = $goal100Decision
    goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null }
    limits = $limitReports
  }
}

function Write-PerformanceAssessment([object]$Assessment) {
  if ($Assessment.goal100Decision) {
    Write-Host "assessment goal100: $($Assessment.goal100Decision)"
  }
  foreach ($limit in @($Assessment.limits)) {
    Write-Host ("assessment limit={0}: winner={1} qualified={2} engines={3} missRate={4:P1} reviewRate={5:P1} activeAvg={6}ms projected100={7}ms roundtripOverheadAvg={8}ms" -f `
      $limit.limit,
      $limit.winnerEngine,
      $limit.winnerQualified,
      ($limit.enginesCompared -join ","),
      $limit.winnerMissRate,
      $limit.winnerReviewRate,
      $limit.winnerActiveAverageMsPerParsed,
      $limit.winnerActiveProjectedMsFor100,
      $limit.winnerAverageCaptureRoundTripOverheadMs)
    foreach ($engine in @($limit.engines)) {
      Write-Host ("  {0}: qualified={1}, missRate={2:P1}, reviewRate={3:P1}, bottleneck={4}, activeAvg={5}ms, ocr={6}ms, capture={7}ms, roundtrip={8}ms, roundtripOverhead={9}ms, cardReady={10}ms, scrollReady={11}ms, decision={12}" -f `
        $engine.engine,
        $engine.qualified,
        $engine.missRate,
        $engine.reviewRate,
        $engine.bottleneck,
        $engine.activeAverageMsPerParsed,
        $engine.averageOcrMs,
        $engine.averageCaptureMs,
        $engine.averageCaptureRoundTripMs,
        $engine.averageCaptureRoundTripOverheadMs,
        $engine.averageCardReadyMs,
        $engine.averageScrollReadyMs,
        $engine.qualityDecision)
    }
  }
}

function Invoke-AssessmentSelfTest {
  $synthetic = @(
    [pscustomobject]@{
      engine = "current"
      limit = 100
      status = "done"
      parsed = 100
      review = 0
      misses = 0
      activeAverageMsPerParsed = 500
      averageMsPerParsed = 520
      activeProjectedMsFor100 = 50000
      averageOcrMs = 180
      averageCaptureMs = 120
      averageCaptureRoundTripMs = 260
      averageCaptureRoundTripOverheadMs = 140
      averageCardReadyMs = 100
      averageScrollReadyMs = 50
    },
    [pscustomobject]@{
      engine = "high-review"
      limit = 100
      status = "done"
      parsed = 100
      review = 22
      misses = 0
      activeAverageMsPerParsed = 300
      averageMsPerParsed = 330
      activeProjectedMsFor100 = 30000
      averageOcrMs = 100
      averageCaptureMs = 90
      averageCaptureRoundTripMs = 190
      averageCaptureRoundTripOverheadMs = 100
      averageCardReadyMs = 40
      averageScrollReadyMs = 20
    },
    [pscustomobject]@{
      engine = "current"
      limit = 20
      status = "done"
      parsed = 20
      review = 1
      misses = 0
      activeAverageMsPerParsed = 390
      averageMsPerParsed = 405
      activeProjectedMsFor100 = 39000
      averageOcrMs = 150
      averageCaptureMs = 130
      averageCaptureRoundTripMs = 280
      averageCaptureRoundTripOverheadMs = 150
      averageCardReadyMs = 80
      averageScrollReadyMs = 0
    },
    [pscustomobject]@{
      engine = "broken-fast"
      limit = 45
      status = "done"
      parsed = 45
      review = 0
      misses = 3
      activeAverageMsPerParsed = 300
      averageMsPerParsed = 330
      activeProjectedMsFor100 = 30000
      averageOcrMs = 100
      averageCaptureMs = 90
      averageCaptureRoundTripMs = 190
      averageCaptureRoundTripOverheadMs = 100
      averageCardReadyMs = 40
      averageScrollReadyMs = 20
    },
    [pscustomobject]@{
      engine = "current"
      limit = 45
      status = "done"
      parsed = 45
      review = 1
      misses = 0
      activeAverageMsPerParsed = 600
      averageMsPerParsed = 650
      activeProjectedMsFor100 = 60000
      averageOcrMs = 230
      averageCaptureMs = 130
      averageCaptureRoundTripMs = 300
      averageCaptureRoundTripOverheadMs = 170
      averageCardReadyMs = 160
      averageScrollReadyMs = 50
    }
  )

  $assessment = New-PerformanceAssessment -Summaries $synthetic
  $goal100 = $assessment.goal100
  $limit20 = @($assessment.limits | Where-Object { $_.limit -eq 20 } | Select-Object -First 1)[0]
  $limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0]

  if ($goal100.winnerEngine -ne "current") {
    throw "Assessment self-test failed: expected current to win limit=100, got '$($goal100.winnerEngine)'."
  }
  if (-not $goal100.winnerQualified) {
    throw "Assessment self-test failed: expected limit=100 winner to be qualified."
  }
  if ($assessment.goal100Decision -ne "qualified: winner=current") {
    throw "Assessment self-test failed: unexpected goal100Decision '$($assessment.goal100Decision)'."
  }
  if ($limit20.winnerEngine -ne "current") {
    throw "Assessment self-test failed: expected current to win limit=20, got '$($limit20.winnerEngine)'."
  }
  if (-not $limit20.winnerQualified) {
    throw "Assessment self-test failed: expected limit=20 winner to be qualified."
  }
  if ($limit45.winnerEngine -ne "current") {
    throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'."
  }
  if (@($goal100.engines | Where-Object { $_.engine -eq "high-review" })[0].qualityDecision -ne "not-qualified: review rate above 15%") {
    throw "Assessment self-test failed: expected high-review run to be rejected."
  }
  if (@($limit45.engines | Where-Object { $_.engine -eq "broken-fast" })[0].qualityDecision -ne "not-qualified: miss rate above 2%") {
    throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate."
  }

  $missingGoalAssessment = New-PerformanceAssessment -Summaries @(
    [pscustomobject]@{
      engine = "current"
      limit = 20
      status = "done"
      parsed = 20
      review = 0
      misses = 0
      activeAverageMsPerParsed = 500
      averageMsPerParsed = 520
      activeProjectedMsFor100 = 50000
      averageOcrMs = 180
      averageCaptureMs = 120
      averageCaptureRoundTripMs = 260
      averageCaptureRoundTripOverheadMs = 140
      averageCardReadyMs = 100
      averageScrollReadyMs = 50
    }
  )
  if ($missingGoalAssessment.goal100Decision -ne "not-run: missing 100-artifact assessment") {
    throw "Assessment self-test failed: expected missing 100 run to be not-run, got '$($missingGoalAssessment.goal100Decision)'."
  }

  Write-PerformanceAssessment $assessment
  Write-Host "Assessment self-test passed." -ForegroundColor Green
  return $assessment
}

function Invoke-OcrBenchmark {
  param(
    [int]$Limit,
    [string]$Engine,
    [string]$Profile
  )

  Write-Host "Warming current OCR workers..."
  $warmCurrent = Invoke-DevJson "/scanner/ocr/warmup"
  Save-Json "benchmark-warmup-current" $warmCurrent | Out-Null

  Write-Host "Running OCR benchmark engine=$Engine profile=$Profile limit=$Limit"
  $benchmark = Invoke-DevJson "/scanner/benchmark-ocr?limit=$Limit&profile=$Profile"
  Save-Json "benchmark-ocr-$Engine-$Profile-limit-$Limit" $benchmark | Out-Null

  $summary = $benchmark.summary
  Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f `
    $summary.engine,
    $summary.averageMs,
    $summary.averageOcrMs,
    $summary.p50Ms,
    $summary.p90Ms,
    $summary.projectedMs.artifacts100,
    $summary.skippedOcrCaptures,
    $summary.workerPoolSize)

  return $benchmark
}

function Convert-ReviewSamplesSummary([object]$ReviewPayload) {
  $samples = @()
  foreach ($record in @($ReviewPayload.samples)) {
    $parsed = $record.sample.parsed
    $capture = $record.sample.capture
    $ocr = @()
    foreach ($entry in @($capture.ocr)) {
      $ocr += [pscustomobject]@{
        id = $entry.id
        label = $entry.label
        text = $entry.text
        confidence = $entry.confidence
      }
    }
    $samples += [pscustomobject]@{
      savedAt = $record.savedAt
      reason = $record.sample.reason
      parsed = [pscustomobject]@{
        name = $parsed.name
        slot = $parsed.slot
        level = $parsed.level
        mainStat = $parsed.mainStat
        mainValue = $parsed.mainValue
        setName = $parsed.setName
        equipped = $parsed.equipped
        confidence = $parsed.confidence
        notes = $parsed.notes
        fields = $parsed.fields
      }
      capture = [pscustomobject]@{
        name = $capture.name
        width = $capture.width
        height = $capture.height
        captureTarget = $capture.captureTarget
        capturedAt = $capture.capturedAt
        inventoryGrid = if ($capture.inventoryGrid) {
          [pscustomobject]@{
            rows = $capture.inventoryGrid.rows
            cols = $capture.inventoryGrid.cols
            confidence = $capture.inventoryGrid.confidence
            source = $capture.inventoryGrid.source
          }
        } else { $null }
        inventoryCount = $capture.inventoryCount
        locked = $capture.locked
        ocr = $ocr
      }
    }
  }

  return [pscustomobject]@{
    ok = $ReviewPayload.ok
    total = $ReviewPayload.total
    path = $ReviewPayload.path
    samples = $samples
  }
}

function Wait-ForScannerIdle([int]$Limit, [string]$Engine) {
  $startedAt = Get-Date
  $pollIndex = 0
  $lastStatus = $null
  $observedMatchingRun = $false

  while ($true) {
    Start-Sleep -Seconds $PollIntervalSeconds
    $pollIndex += 1
    $statusPayload = Get-ScannerStatus
    $lastStatus = $statusPayload
    Save-Json "scan-$Engine-limit-$Limit-poll-$pollIndex" $statusPayload | Out-Null

    $running = [bool]$statusPayload.status.running
    $summaryTarget = if ($statusPayload.status.summary) { [int]$statusPayload.status.summary.targetCount } else { 0 }
    $scanStart = @($statusPayload.status.diagnosticEvents | Where-Object { $_.phase -eq "scan-start" } | Select-Object -Last 1)
    $scanStartDetails = if ($scanStart.Count -gt 0) { $scanStart[0].details } else { $null }
    $scanStartLimit = if ($scanStartDetails -and $scanStartDetails.scanLimit) { [int]$scanStartDetails.scanLimit } else { 0 }
    $scanStartEngine = if ($scanStartDetails -and $scanStartDetails.ocrEngine) { [string]$scanStartDetails.ocrEngine } else { "" }
    $matchesScanStart = $scanStartLimit -eq $Limit -and ($scanStartEngine -eq "" -or $scanStartEngine -eq $Engine)
    $matchesFinalSummary = $summaryTarget -eq $Limit
    if ($running -and ($matchesScanStart -or $matchesFinalSummary)) {
      $observedMatchingRun = $true
    }
    if (-not $running) {
      if ($observedMatchingRun -or $matchesScanStart -or $matchesFinalSummary) {
        return $statusPayload
      }
      Write-Host "Waiting for scanner run limit=$Limit engine=$Engine to appear; ignoring unrelated idle status." -ForegroundColor DarkGray
    }

    $elapsed = ((Get-Date) - $startedAt).TotalSeconds
    if ($elapsed -gt $TimeoutSeconds) {
      $stop = Invoke-DevJson "/scanner/stop"
      Save-Json "scan-$Engine-limit-$Limit-timeout-stop" $stop | Out-Null
      throw "Scanner timed out after $TimeoutSeconds seconds for limit=$Limit engine=$Engine. Stop command was sent."
    }
  }
}

if ($SelfTestAssessment) {
  Invoke-AssessmentSelfTest | Out-Null
  exit 0
}

$stamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
$RunDir = Join-Path $OutputRoot $stamp
New-Item -ItemType Directory -Force -Path $RunDir | Out-Null

if ($GoalRun) {
  $Limits = @(2, 5, 20, 45, 100)
  $BenchmarkOcr = $true
}
if ($RepeatabilityRun) {
  $Limits = @(20, 45, 100)
}

$Limits = @($Limits | ForEach-Object {
  $limit = [int]$_
  if ($limit -lt 1 -or $limit -gt 1800) {
    throw "Refusing unsafe scan limit '$limit'. Expected a bounded value from 1 to 1800."
  }
  $limit
})

$ScanEngines = @($ScanEngine)
$RunSummaries = @()

$transcriptPath = Join-Path $RunDir "transcript.log"
try {
  Start-Transcript -Path $transcriptPath -Append | Out-Null
} catch {
  Write-Warning "Could not start transcript: $($_.Exception.Message)"
}

try {
  Write-Host "Live soak output: $RunDir"
  Write-Host "Checking dev control server at $BaseUrl"

  $health = Invoke-DevJson "/health"
  Save-Json "00-health" $health | Out-Null
  Assert-CurrentAppBuild $health

  if ($health.appBuild) {
    Write-Host "App build: signature=$($health.appBuild.signature), pid=$($health.appBuild.pid), startedAt=$($health.appBuild.startedAt), ocrWorkers=$($health.appBuild.expectedOcrWorkerPoolSize)"
    if ($health.appBuild.expectedOcrWorkerPoolSize -lt 4) {
      Write-Host "WARNUNG: OCR worker pool is below 4. This is valid for constrained debugging, but not ideal for scanner timing." -ForegroundColor Yellow
    }
  }

  $initialStatus = Get-ScannerStatus
  Save-Json "01-status-before" $initialStatus | Out-Null

  if ($initialStatus.status.runtimeInfo) {
    $runtime = $initialStatus.status.runtimeInfo
    Write-Host "Runtime: elevated=$($runtime.isElevated), genshinFound=$($runtime.genshinFound), target=$($runtime.targetProcess)"
  } else {
    Write-Host "Runtime info is not published yet. Open the app scanner view before running broad scans." -ForegroundColor Yellow
  }

  if ($BenchmarkOcr) {
    Invoke-OcrBenchmark -Limit $BenchmarkLimit -Engine $BenchmarkEngine -Profile $BenchmarkProfile | Out-Null
  }

  if (-not $SkipSmartCapture) {
    Write-Host "Capturing smart preflight snapshot without OCR..."
    $capture = Invoke-DevJson "/capture/smart?skipOcr=1"
    Save-Json "02-smart-capture-skip-ocr" $capture | Out-Null
  }

  foreach ($index in $ProbeIndices) {
    Write-Host "Running probe click index=$index"
    $probe = Invoke-DevJson "/automation/probe-click?index=$index"
    Save-Json "probe-index-$index" $probe | Out-Null
    if (-not (Test-ProbeSucceeded $probe)) {
      Write-Host "Probe index=$index did not fully pass. Review the saved JSON before broader scans." -ForegroundColor Yellow
      if (-not $ContinueAfterBlocked) {
        throw "Stopping after failed probe index=$index. Re-run with -ContinueAfterBlocked only if you deliberately want to continue."
      }
    } elseif (-not $probe.ok -and $probe.changed) {
      Write-Host "Probe index=$index changed the detail panel even though helper cursor/click readback was not clean; continuing." -ForegroundColor Yellow
    } elseif (-not $probe.ok -and $probe.click -and $probe.click.clicked -and $probe.click.moved -and -not $probe.click.inputBlocked) {
      Write-Host "Probe index=$index delivered input but detail did not change; continuing because the target may already be selected." -ForegroundColor Yellow
    }
  }

  foreach ($engine in $ScanEngines) {
    foreach ($limit in $Limits) {
      if ($limit -lt 1) { continue }
      Write-Host "Starting bounded scanner run limit=$limit engine=$engine"
      $start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit"
      Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null

      $finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine
      Save-Json "scan-$engine-limit-$limit-final" $finalStatus | Out-Null

      $summary = Get-CompletedScanSummary $finalStatus $limit
      Write-Host "engine=$engine limit=$limit summary: status=$($summary.status), attempted=$($summary.attempted), verified=$($summary.verified), parsed=$($summary.parsed), stored=$($summary.stored), review=$($summary.review), misses=$($summary.misses), pages=$($summary.pages)"
      $timing = Convert-ScanTimingSummary $finalStatus $limit $engine
      $RunSummaries += $timing
      Write-ScanTimingLine $timing

      if (($summary.status -eq "blocked" -or $summary.status -eq "stopped") -and -not $ContinueAfterBlocked) {
        throw "Stopping after scan status '$($summary.status)' for limit=$limit engine=$engine."
      }
    }
  }

  $review = Invoke-DevJson "/review/samples?limit=30"
  Save-Json "review-samples-tail-summary" (Convert-ReviewSamplesSummary $review) | Out-Null
  if ($SaveFullReviewSamples) {
    Save-Json "review-samples-tail-full" $review | Out-Null
  }

  $afterStatus = Get-ScannerStatus
  Save-Json "99-status-after" $afterStatus | Out-Null
  Save-Json "scan-run-summary" ([pscustomobject]@{
    createdAt = (Get-Date).ToString("o")
    goalRun = [bool]$GoalRun
    scanEngine = $ScanEngine
    scanEngines = $ScanEngines
    benchmarkOcr = [bool]$BenchmarkOcr
    limits = $Limits
    summaries = $RunSummaries
  }) | Out-Null

  if ($RunSummaries.Count -gt 0) {
    $assessment = New-PerformanceAssessment -Summaries $RunSummaries
    Save-Json "scan-performance-assessment" $assessment | Out-Null
    Write-PerformanceAssessment $assessment

    $csvPath = Join-Path $RunDir "scan-run-summary.csv"
    $RunSummaries | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
    Write-Host "Timing summary CSV: $csvPath"
  }

  Write-Host "Live soak complete. Output: $RunDir" -ForegroundColor Green
} catch {
  Write-Host "Live soak failed: $($_.Exception.Message)" -ForegroundColor Red
  Write-Host "Output so far: $RunDir" -ForegroundColor Yellow
  exit 1
} finally {
  try {
    Stop-Transcript | Out-Null
  } catch {
  }
}
