$ErrorActionPreference = 'Stop'
try {
  [Console]::InputEncoding = [Text.UTF8Encoding]::new($false)
  [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
  $OutputEncoding = [Text.UTF8Encoding]::new($false)
} catch {}
$AgentVersion = '6.0.4'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$configPath = Join-Path $root 'config.json'
$profilePath = Join-Path $root 'browser-profile'
$logPath = Join-Path $root 'agent.log'
$badPostPath = Join-Path $root 'bad-posts.txt'
$script:browserProcess = $null
$script:paused = $false
$script:debugPort = 39277
$script:cdpId = 0
$script:targetQueue = @()
$script:lastActionAt = [DateTime]::MinValue
$script:lastConfig = $null
$script:sourceTypeIndex = 0
$script:sourcePickIndex = @{ hashtag = 0; competitor = 0; location = 0 }
$script:badPostSet = New-Object 'System.Collections.Generic.HashSet[string]'
$script:badPostOrder = @()
# v5.8.14: contas ja confirmadas como privadas nesta execucao do Agent.
# Follow ainda tenta (pode mandar solicitacao); like/comment nunca vao
# adiantar em conta privada, entao sao filtrados da fila usando este cache -
# evita reabrir o perfil de novo so pra redescobrir o que ja sabemos.
$script:privateAccountSet = New-Object 'System.Collections.Generic.HashSet[string]'
$script:lastSourceInfo = @{ type=''; label=''; url='' }
$script:lastTargetSearchTrace = @()
$script:lastTargetCollectionReason = ''
$script:consecutiveRateLimitHits = 0
$script:sourceTypeCooldownUntil = @{}
$script:lastUnfollowFailureTarget = ''
$script:lastUnfollowFailureReason = ''
$script:lastUnfollowFailureCount = 0
# v5.8.5: cooldown por concorrente especifico (nao so por tipo de fonte) -
# sem isso, um concorrente problematico (perfil privado, ou que o Instagram
# esta rejeitando por algum motivo especifico dele) pode ser sorteado de
# novo repetidamente e consumir a rodada toda, mesmo com Pick-SourceItem
# escolhendo aleatoriamente entre varios concorrentes configurados. Ver
# Is-CompetitorCoolingDown/Register-CompetitorFailure mais abaixo.
$script:competitorFailureCounts = @{}
$script:competitorCooldownUntil = @{}
# v5.9.17: confirmado por Marcelo que a Instagram as vezes bloqueia uma acao
# especifica (ex: follow) de verdade - o clique acontece mas a acao nunca se
# confirma, mesmo tentando manualmente fora da automacao. Antes o sistema so
# via isso como "esse alvo especifico falhou" e ia pro proximo candidato,
# sem nunca reconhecer que o problema e da conta/acao, nao do alvo. Agora
# conta falhas consecutivas do tipo "clique confirmado mas sem efeito" por
# tipo de acao (em alvos diferentes, nao o mesmo alvo repetido - isso ja e
# tratado separadamente pro unfollow acima), e poe essa acao especifica em
# espera por um tempo quando o padrao se repete demais.
$script:notConfirmedStreak = @{ follow = 0; unfollow = 0; like = 0; comment = 0 }
$script:actionBlockedUntil = @{ follow = [DateTime]::MinValue; unfollow = [DateTime]::MinValue; like = [DateTime]::MinValue; comment = [DateTime]::MinValue }
$script:NotConfirmedReasonByAction = @{ follow = 'click_not_confirmed'; unfollow = 'unfollow_not_confirmed'; like = 'like_not_confirmed'; comment = 'comment_not_visible_after_8s' }
$script:NOT_CONFIRMED_STREAK_LIMIT = 5
$script:ACTION_BLOCK_COOLDOWN_MINUTES = 45
# v5.9.7: cache local (por execucao) da lista de concorrentes restritos
# vinda do servidor - individual por conta, compartilhada entre Agent e
# Extensao. So reconsulta a cada 10min (Sync-RestrictedCompetitorsFromServer),
# nao a cada rodada.
$script:serverRestrictedCompetitors = New-Object 'System.Collections.Generic.HashSet[string]'
$script:lastRestrictedSyncAt = [DateTime]::MinValue
# v5.6.1: instrumentacao de rede (so ativa durante o clique de follow, ver
# Start-FollowNetworkCapture/Stop-FollowNetworkCapture) e espacamento extra
# minimo entre tentativas de follow (ver Wait-FollowExtraSpacing) - as duas
# sao mitigacao/diagnostico temporarios pro click_not_confirmed do follow
# (~45% de falha mesmo com o retry da v5.6.0 ja ativo).
$script:captureNetworkEvents = $false
$script:networkEventLog = @{}
$script:lastFollowAttemptAt = $null
$script:clickHitLog = @()
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue

$script:CommentExtras = @(
  'top', 'demais', 'muito bom', 'parabens', 'adorei', 'show',
  'valeu', 'que legal', 'excelente', 'continue assim', 'mandou bem',
  'conteúdo bom', 'muito util', 'obrigado pela dica'
)

function Write-AgentLog([string]$message) {
  $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $message"
  Add-Content -LiteralPath $logPath -Value $line -Encoding UTF8
}

function Protect-Token([string]$token) {
  $bytes = [Text.Encoding]::UTF8.GetBytes($token)
  return "b64:$([Convert]::ToBase64String($bytes))"
}

function Unprotect-Token([string]$token) {
  if ($token -like 'b64:*') {
    return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($token.Substring(4)))
  }
  $protectedData = 'System.Security.Cryptography.ProtectedData' -as [type]
  if (-not $protectedData) { throw 'Não foi possível ler o token salvo. Apague config.json e vincule o Agent novamente.' }
  $bytes = $protectedData::Unprotect([Convert]::FromBase64String($token), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
  return [Text.Encoding]::UTF8.GetString($bytes)
}

function Find-Browser {
  $paths = @(
    "$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
    "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
    "$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe",
    "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe",
    "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe"
  )
  return $paths | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
}

function Quote-BrowserArg([string]$value) {
  return '"' + ($value -replace '"', '\"') + '"'
}

function Get-AgentBrowserProcesses {
  $profileFull = [IO.Path]::GetFullPath($profilePath)
  $profileAlt = $profileFull -replace '\\', '/'
  try {
    return @(Get-CimInstance Win32_Process | Where-Object {
      ($_.Name -eq 'chrome.exe' -or $_.Name -eq 'msedge.exe') -and
      $_.CommandLine -and
      (($_.CommandLine -like "*--user-data-dir=$profileFull*") -or ($_.CommandLine -like "*--user-data-dir=`"$profileFull`"*") -or ($_.CommandLine -like "*--user-data-dir=$profileAlt*"))
    })
  } catch {
    Write-AgentLog "Não foi possível listar processos do navegador: $($_.Exception.Message)"
    return @()
  }
}

function Test-AgentDevTools {
  try {
    Invoke-RestMethod -Uri "http://127.0.0.1:$script:debugPort/json/version" -TimeoutSec 1 | Out-Null
    return $true
  } catch {
    return $false
  }
}

function Stop-AgentBrowser {
  if ($script:browserProcess -and -not $script:browserProcess.HasExited) {
    Stop-Process -Id $script:browserProcess.Id -Force -ErrorAction SilentlyContinue
  }
  foreach ($proc in @(Get-AgentBrowserProcesses)) {
    Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
  }
  $script:browserProcess = $null
}

function Register-Agent {
  Write-Host 'Abra o painel FlowInsta BR > FlowInsta BR Agent > Gerar código.' -ForegroundColor Yellow
  $server = Read-Host 'Endereco do painel [https://app.flowinstabr.com.br]'
  if ([string]::IsNullOrWhiteSpace($server)) { $server = 'https://app.flowinstabr.com.br' }
  $code = Read-Host 'Digite o código de 6 números'
  $name = Read-Host 'Nome deste equipamento [Meu Windows]'
  if ([string]::IsNullOrWhiteSpace($name)) { $name = "Windows-$env:COMPUTERNAME" }
  $body = @{ code=$code; platform='windows'; name=$name; version=$AgentVersion; capabilities=@('browser','cdp','direct_automation','auto_update') } | ConvertTo-Json
  $response = Invoke-RestMethod -Method Post -Uri "$($server.TrimEnd('/'))/api/agents/register" -ContentType 'application/json' -Body $body
  $config = @{
    server = $server.TrimEnd('/')
    agent_id = $response.agent_id
    account_id = $response.account_id
    token = Protect-Token $response.token
    name = $name
  }
  $config | ConvertTo-Json | Set-Content -LiteralPath $configPath -Encoding UTF8
  Write-Host 'Agent vinculado com sucesso.' -ForegroundColor Green
  return $config
}

function Headers($token) {
  return @{ Authorization = "Bearer $token" }
}

function ApiGet($config, $token, [string]$path) {
  Invoke-RestMethod -Method Get -Uri "$($config.server)$path" -Headers (Headers $token)
}

function ApiPost($config, $token, [string]$path, $body) {
  Invoke-RestMethod -Method Post -Uri "$($config.server)$path" -Headers (Headers $token) -ContentType 'application/json' -Body ($body | ConvertTo-Json -Depth 12)
}

function Reserve-AgentAction($config, $token, [string]$type, [string]$target) {
  try {
    return ApiPost $config $token '/api/agents/actions/reserve' @{ type=$type; target=$target }
  } catch {
    Write-AgentLog "Reserva global recusada para $type em @${target}: $($_.Exception.Message)"
    return @{ allowed=$false; reason='daily_limit_reached_or_quota_unavailable' }
  }
}

function Start-AgentBrowser([string]$username = '') {
  if ($script:paused) { return }
  if (Test-AgentDevTools) {
    $script:browserProcess = $null
    return
  }
  $staleProcesses = @(Get-AgentBrowserProcesses)
  if ($staleProcesses.Count -gt 0) {
    Write-AgentLog "Fechando navegador antigo do Agent sem DevTools ativo: $($staleProcesses.Count) processo(s)"
    foreach ($proc in $staleProcesses) {
      Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
    }
    Start-Sleep -Seconds 2
  }
  $browser = Find-Browser
  if (-not $browser) { throw 'Google Chrome ou Microsoft Edge não encontrado.' }
  New-Item -ItemType Directory -Path $profilePath -Force | Out-Null
  $url = if ($username) { "https://www.instagram.com/$username/" } else { 'https://www.instagram.com/' }
  $args = @(
    "--user-data-dir=$(Quote-BrowserArg $profilePath)",
    "--remote-debugging-port=$script:debugPort",
    '--no-first-run',
    '--disable-default-apps',
    '--disable-notifications',
    '--deny-permission-prompts',
    '--new-window',
    # v5.8.0: sem essas 3 flags, o Chrome reduz a prioridade de renderizacao
    # (throttling) de uma janela coberta por outra janela ou minimizada, pra
    # economizar CPU/bateria - isso pode deixar getBoundingClientRect() e
    # elementFromPoint() com geometria desatualizada bem na hora do clique,
    # o que bate com o padrao observado de acoes falhando mais quando o
    # Chrome do Agent nao esta em primeiro plano. Mesma combinacao usada por
    # ferramentas de automacao (Puppeteer/Playwright) exatamente pra isso -
    # afeta so o perfil isolado deste Agent, nao o Chrome pessoal do usuario.
    '--disable-backgrounding-occluded-windows',
    '--disable-renderer-backgrounding',
    '--disable-background-timer-throttling',
    # v5.8.1: as 3 flags acima nao resolveram sozinhas (confirmado - Chrome
    # visivel funciona, coberto por outra janela falha mais). Essa aqui e
    # diferente: "Native Window Occlusion" e um recurso do proprio WINDOWS
    # (nao do Chrome) que avisa ativamente o navegador quando a janela esta
    # coberta por outra - o Chrome usa esse aviso do sistema operacional pra
    # throttlar a pagina, por fora do controle das 3 flags anteriores (que so
    # cobrem o comportamento interno do Chrome). Essa flag desliga esse sinal
    # do Windows na raiz.
    '--disable-features=CalculateNativeWinOcclusion',
    $url
  ) -join ' '
  $script:browserProcess = Start-Process -FilePath $browser -ArgumentList $args -PassThru
  Write-AgentLog "Navegador iniciado sem extensão: $browser"
  for ($i = 0; $i -lt 20; $i++) {
    if (Test-AgentDevTools) { return }
    Start-Sleep -Milliseconds 500
  }
  throw 'Chrome do Agent abriu, mas não respondeu na porta local 39277.'
}

function Get-TargetWebSocketUrl {
  for ($i = 0; $i -lt 20; $i++) {
    try {
      $tabs = Invoke-RestMethod -Uri "http://127.0.0.1:$script:debugPort/json/list" -TimeoutSec 2
      $tab = @($tabs | Where-Object { $_.type -eq 'page' -and $_.url -like 'https://www.instagram.com/*' } | Select-Object -First 1)
      if (-not $tab) { $tab = @($tabs | Where-Object { $_.type -eq 'page' } | Select-Object -First 1) }
      if ($tab -and $tab.webSocketDebuggerUrl) { return $tab.webSocketDebuggerUrl }
      $created = Invoke-RestMethod -Method Put -Uri "http://127.0.0.1:$script:debugPort/json/new?https%3A%2F%2Fwww.instagram.com%2F" -TimeoutSec 2
      if ($created -and $created.webSocketDebuggerUrl) { return $created.webSocketDebuggerUrl }
    } catch {}
    Start-Sleep -Milliseconds 500
  }
  throw 'Não consegui conectar ao navegador do Agent pela porta DevTools.'
}

function New-CdpSocket {
  $ws = [Net.WebSockets.ClientWebSocket]::new()
  $cts = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(20))
  try {
    [void]$ws.ConnectAsync([Uri](Get-TargetWebSocketUrl), $cts.Token).GetAwaiter().GetResult()
  } finally {
    $cts.Dispose()
  }
  return $ws
}

function Receive-CdpMessage($ws) {
  $buffer = New-Object byte[] 65536
  $ms = [IO.MemoryStream]::new()
  do {
    $segment = [ArraySegment[byte]]::new($buffer)
    $cts = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(25))
    try {
      $result = $ws.ReceiveAsync($segment, $cts.Token).GetAwaiter().GetResult()
    } finally {
      $cts.Dispose()
    }
    if ($result.Count -gt 0) { $ms.Write($buffer, 0, $result.Count) }
  } until ($result.EndOfMessage)
  return [Text.Encoding]::UTF8.GetString($ms.ToArray()) | ConvertFrom-Json
}

# v5.6.1: coleta passivamente eventos do dominio Network (quando ligado por
# Start-FollowNetworkCapture) que passam pelo loop de recebimento do
# Invoke-Cdp - hoje TODO evento sem 'id' correspondente (Page.*, Network.*,
# etc) e so descartado nesse loop; isso so guarda o que interessa, sem
# mudar esse comportamento pra ninguem que nao ligou a captura. NUNCA pode
# lancar excecao aqui - isso roda dentro do caminho mais usado de todo o
# Agent (toda chamada de CDP passa por aqui).
function Capture-CdpEvent($msg) {
  if (-not $script:captureNetworkEvents) { return }
  try {
    if (-not $msg -or -not $msg.method) { return }
    $reqId = [string]$msg.params.requestId
    if (-not $reqId) { return }
    if (-not $script:networkEventLog.ContainsKey($reqId)) { $script:networkEventLog[$reqId] = @{} }
    if ($msg.method -eq 'Network.requestWillBeSent') {
      $script:networkEventLog[$reqId]['url'] = [string]$msg.params.request.url
      $script:networkEventLog[$reqId]['requestMethod'] = [string]$msg.params.request.method
      $script:networkEventLog[$reqId]['type'] = [string]$msg.params.type
    } elseif ($msg.method -eq 'Network.responseReceived') {
      $script:networkEventLog[$reqId]['status'] = $msg.params.response.status
    } elseif ($msg.method -eq 'Network.loadingFailed') {
      $script:networkEventLog[$reqId]['failed'] = $true
      $script:networkEventLog[$reqId]['errorText'] = [string]$msg.params.errorText
    }
  } catch { }
}

function Invoke-Cdp($ws, [string]$method, $params = @{}) {
  $script:cdpId++
  $payload = @{ id=$script:cdpId; method=$method; params=$params } | ConvertTo-Json -Depth 20 -Compress
  $bytes = [Text.Encoding]::UTF8.GetBytes($payload)
  $sendCts = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(15))
  try {
    [void]$ws.SendAsync([ArraySegment[byte]]::new($bytes), [Net.WebSockets.WebSocketMessageType]::Text, $true, $sendCts.Token).GetAwaiter().GetResult()
  } finally {
    $sendCts.Dispose()
  }
  $deadline = (Get-Date).AddSeconds(60)
  while ($true) {
    if ((Get-Date) -gt $deadline) { throw "cdp_response_timeout: $method" }
    $msg = Receive-CdpMessage $ws
    if ($msg.id -eq $script:cdpId) { return $msg }
    Capture-CdpEvent $msg
  }
}

function Navigate-Cdp($ws, [string]$url, [int]$waitSeconds = 7) {
  Invoke-Cdp $ws 'Page.enable' @{} | Out-Null
  Invoke-Cdp $ws 'Page.navigate' @{ url=$url } | Out-Null
  Start-Sleep -Seconds $waitSeconds
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
}

function Eval-Cdp($ws, [string]$expression) {
  $res = Invoke-Cdp $ws 'Runtime.evaluate' @{ expression=$expression; returnByValue=$true; awaitPromise=$true }
  if ($res.error) {
    $message = if ($res.error.message) { $res.error.message } else { 'cdp_error' }
    return @{ ok=$false; reason=$message; detail=($res.error | ConvertTo-Json -Compress) }
  }
  if (-not $res.result) {
    return @{ ok=$false; reason='cdp_empty_result' }
  }
  if ($res.result.exceptionDetails) {
    return @{ ok=$false; reason='js_exception'; detail=($res.result.exceptionDetails.text | Out-String) }
  }
  $value = $res.result.result.value
  if ($value -is [string] -and $value.Trim().StartsWith('{')) {
    return $value | ConvertFrom-Json
  }
  return $value
}

function Dismiss-InstagramBlockingDialogs($ws) {
  $js = @'
(() => {
  try {
    const clean = value => String(value || '')
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .trim()
      .toLowerCase();
    const visible = el => {
      if (!el) return false;
      const rect = el.getBoundingClientRect();
      const style = getComputedStyle(el);
      return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
    };
    const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter(visible);
    for (const dialog of dialogs) {
      const text = clean(dialog.innerText || dialog.textContent || '');
      const isBlockingPrompt =
        text.includes('ativar notificacoes') ||
        text.includes('ative as notificacoes') ||
        text.includes('turn on notifications') ||
        text.includes('allow notifications') ||
        text.includes('notifications from instagram') ||
        text.includes('salvar suas informacoes de login') ||
        text.includes('save your login info') ||
        text.includes('adicionar instagram a sua tela inicial') ||
        text.includes('add instagram to your home screen');
      if (!isBlockingPrompt) continue;
      const buttons = Array.from(dialog.querySelectorAll('button, [role="button"]')).filter(visible);
      const dismiss = buttons.find(btn => {
        const label = clean(btn.innerText || btn.textContent || btn.getAttribute('aria-label') || '');
        if (!label) return false;
        if (label.includes('ativar') || label.includes('turn on') || label.includes('permitir') || label.includes('allow')) return false;
        return label.includes('agora nao') || label.includes('not now') || label.includes('depois') ||
          label.includes('cancelar') || label.includes('fechar') || label.includes('close') ||
          label.includes('dispensar') || label.includes('dismiss');
      });
      if (dismiss) {
        dismiss.click();
        return JSON.stringify({ ok:true, dismissed:true, method:'button', label: clean(dismiss.innerText || dismiss.textContent || dismiss.getAttribute('aria-label') || '') });
      }
      document.dispatchEvent(new KeyboardEvent('keydown', { key:'Escape', code:'Escape', keyCode:27, which:27, bubbles:true }));
      document.dispatchEvent(new KeyboardEvent('keyup', { key:'Escape', code:'Escape', keyCode:27, which:27, bubbles:true }));
      return JSON.stringify({ ok:true, dismissed:true, method:'escape' });
    }
    return JSON.stringify({ ok:true, dismissed:false });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'dismiss_dialog_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  try {
    $res = Eval-Cdp $ws $js
    if ($res.dismissed) {
      Write-AgentLog "Modal do Instagram fechado automaticamente: method=$($res.method) label=$($res.label)"
      Start-Sleep -Milliseconds 500
    }
    return $res
  } catch {
    Write-AgentLog "Falha ao tentar fechar modal do Instagram: $($_.Exception.Message)"
    return @{ ok=$false; reason='dismiss_dialog_failed'; detail=$_.Exception.Message }
  }
}

function Invoke-CdpMouseEvent($ws, [string]$type, [double]$x, [double]$y, [string]$button = 'none', [int]$clickCount = 0) {
  $params = @{ type = $type; x = [Math]::Round($x, 1); y = [Math]::Round($y, 1) }
  if ($button -ne 'none') { $params.button = $button }
  if ($clickCount -gt 0) { $params.clickCount = $clickCount }
  Invoke-Cdp $ws 'Input.dispatchMouseEvent' $params | Out-Null
}

# Clique de verdade via protocolo CDP (Input.dispatchMouseEvent), nao evento
# sintetico de DOM. O navegador processa isso como input real (isTrusted:true),
# passando pelo hit-testing normal do Chrome - o mesmo caminho que um clique
# de mouse fisico. Move o cursor em alguns passos com pequena variação até o
# alvo (nao teleporta), pausa antes de pressionar, segura por um instante e
# solta - cada tempo sorteado dentro de uma faixa, nunca o mesmo valor fixo.
function Invoke-CdpHumanClick($ws, [double]$targetX, [double]$targetY) {
  $steps = Get-Random -Minimum 3 -Maximum 6
  $startX = [Math]::Max(0, $targetX + (Get-Random -Minimum -120 -Maximum 120))
  $startY = [Math]::Max(0, $targetY + (Get-Random -Minimum -80 -Maximum 80))
  for ($i = 1; $i -le $steps; $i++) {
    $t = $i / [double]$steps
    $wobbleX = Get-Random -Minimum -3 -Maximum 3
    $wobbleY = Get-Random -Minimum -3 -Maximum 3
    $ix = $startX + (($targetX - $startX) * $t) + $wobbleX
    $iy = $startY + (($targetY - $startY) * $t) + $wobbleY
    Invoke-CdpMouseEvent $ws 'mouseMoved' $ix $iy
    Start-Sleep -Milliseconds (Get-Random -Minimum 12 -Maximum 40)
  }
  Invoke-CdpMouseEvent $ws 'mouseMoved' $targetX $targetY
  Start-Sleep -Milliseconds (Get-Random -Minimum 30 -Maximum 90)
  Invoke-CdpMouseEvent $ws 'mousePressed' $targetX $targetY 'left' 1
  Start-Sleep -Milliseconds (Get-Random -Minimum 40 -Maximum 130)
  Invoke-CdpMouseEvent $ws 'mouseReleased' $targetX $targetY 'left' 1
}

function Is-WithinSchedule($settings) {
  $startText = if ($settings.time_start) { $settings.time_start } else { '08:00' }
  $endText = if ($settings.time_end) { $settings.time_end } else { '22:00' }
  $start = [TimeSpan]::Parse($startText)
  $end = [TimeSpan]::Parse($endText)
  $now = (Get-Date).TimeOfDay
  if ($start -le $end) { return $now -ge $start -and $now -le $end }
  return $now -ge $start -or $now -le $end
}

# v5.9.20: mesmo ajuste feito na extensao - em vez de checar de 60 em 60s
# a noite inteira fora do horario, calcula direto quantos minutos faltam
# pro horario abrir (com teto de 15min, pra continuar responsivo se
# alguem mudar o horario pelo painel durante a espera).
function Get-MinutesUntilScheduleOpens($settings) {
  $startText = if ($settings.time_start) { $settings.time_start } else { '08:00' }
  $endText = if ($settings.time_end) { $settings.time_end } else { '22:00' }
  $start = [TimeSpan]::Parse($startText)
  $end = [TimeSpan]::Parse($endText)
  $now = (Get-Date).TimeOfDay
  if ($start -le $end) {
    if ($now -lt $start) { return [int](($start - $now).TotalMinutes) }
    return [int]((([TimeSpan]::FromHours(24) - $now) + $start).TotalMinutes)
  }
  return [Math]::Max([int](($start - $now).TotalMinutes), 1)
}

function DailyCount($configData, [string]$type) {
  $rows = @($configData.today)
  $sum = 0
  foreach ($row in $rows) {
    if ($row.type -eq $type -and $row.status -eq 'ok') { $sum += [int]$row.total }
  }
  return $sum
}

function Is-ActionBlocked([string]$action) {
  return ($script:actionBlockedUntil.ContainsKey($action) -and $script:actionBlockedUntil[$action] -gt (Get-Date))
}

function Get-ActionBlockCooldownSeconds {
  $now = Get-Date
  $until = $null
  foreach ($action in @('follow','unfollow','like','comment')) {
    if ($script:actionBlockedUntil.ContainsKey($action) -and $script:actionBlockedUntil[$action] -gt $now) {
      if (-not $until -or $script:actionBlockedUntil[$action] -lt $until) {
        $until = $script:actionBlockedUntil[$action]
      }
    }
  }
  if (-not $until) { return 0 }
  return [Math]::Max(1, [int][Math]::Ceiling(($until - $now).TotalSeconds))
}

# v5.9.18: converte o mapa interno (objetos DateTime) pra um hashtable so
# com as acoes ATUALMENTE pausadas, em ISO, pra mandar pro servidor mostrar
# no painel (Resumo). Acoes cujo prazo ja passou nao entram.
function Get-ActiveActionBlocksForReport {
  $now = Get-Date
  $out = @{}
  foreach ($action in @('follow','unfollow','like','comment')) {
    if ($script:actionBlockedUntil.ContainsKey($action) -and $script:actionBlockedUntil[$action] -gt $now) {
      $out[$action] = $script:actionBlockedUntil[$action].ToUniversalTime().ToString('o')
    }
  }
  return $out
}

function EnabledActions($settings) {
  $actions = @($settings.actions_enabled | Where-Object { $_ -in @('follow','like','comment') })
  return $actions | Where-Object {
    ((($_ -eq 'follow' -and [int]$settings.follows_per_day -gt 0) -or
    ($_ -eq 'like' -and [int]$settings.likes_per_day -gt 0) -or
    ($_ -eq 'comment' -and [int]$settings.comments_per_day -gt 0))) -and -not (Is-ActionBlocked $_)
  }
}

function ActionLimit($settings, [string]$action) {
  if ($action -eq 'follow') { return [int]$settings.follows_per_day }
  if ($action -eq 'like') { return [int]$settings.likes_per_day }
  if ($action -eq 'comment') { return [int]$settings.comments_per_day }
  if ($action -eq 'unfollow') { return [int]$settings.unfollows_per_day }
  return 0
}

function AvailableNormalActions($configData) {
  if ((Get-ActionBlockCooldownSeconds) -gt 0) { return @() }
  $settings = $configData.settings
  return @(EnabledActions $settings | Where-Object {
    (DailyCount $configData $_) -lt (ActionLimit $settings $_)
  })
}

function AvailableUnfollowAction($configData) {
  if ((Get-ActionBlockCooldownSeconds) -gt 0) { return $false }
  $settings = $configData.settings
  $limit = ActionLimit $settings 'unfollow'
  return ($limit -gt 0 -and (DailyCount $configData 'unfollow') -lt $limit -and -not (Is-ActionBlocked 'unfollow'))
}

# ------------------------------------------------------------------
# Speed-aware pacing, ported 1:1 from extensao-chrome/background.js
# (randomDelay + automationDelay) so the Windows Agent respects
# "Velocidade: lenta/normal/rápida" the same way the Chrome Extension
# already does. Previously the Agent ignored $settings.speed entirely
# and used flat 90s/8s waits regardless of the configured speed.
# ------------------------------------------------------------------
function Get-TotalDailyActionLimit($settings) {
  $sum = 0
  foreach ($a in @(EnabledActions $settings)) { $sum += (ActionLimit $settings $a) }
  $sum += (ActionLimit $settings 'unfollow')
  return $sum
}

function Get-ScheduleWindowMinutes($settings) {
  $startText = if ($settings.time_start) { $settings.time_start } else { '08:00' }
  $endText = if ($settings.time_end) { $settings.time_end } else { '22:00' }
  try {
    $startMin = [int]([TimeSpan]::Parse($startText)).TotalMinutes
    $endMin = [int]([TimeSpan]::Parse($endText)).TotalMinutes
  } catch {
    $startMin = 480
    $endMin = 1320
  }
  if ($startMin -eq $endMin) { return 1440 }
  if ($startMin -lt $endMin) { return [Math]::Max($endMin - $startMin, 60) }
  return [Math]::Max((1440 - $startMin) + $endMin, 60)
}

function Get-SpeedRandomDelayMs([string]$speed) {
  $ranges = @{ slow = @(50000, 100000); normal = @(25000, 55000); fast = @(10000, 25000) }
  $range = if ($ranges.ContainsKey($speed)) { $ranges[$speed] } else { $ranges['normal'] }
  return [int](Get-Random -Minimum $range[0] -Maximum $range[1])
}

# Mirrors extensao-chrome/background.js automationDelay(): the larger of
# (a) a random speed-range roll and (b) the daily-limit/schedule-window
# pace, scaled by the speed factor and a +/-20% jitter.
function Get-AutomationDelaySeconds($settings) {
  $speed = [string]$settings.speed
  if (-not $speed) { $speed = 'normal' }
  $speedDelayMs = Get-SpeedRandomDelayMs $speed
  $limit = [Math]::Max((Get-TotalDailyActionLimit $settings), 1)
  $windowMinutes = Get-ScheduleWindowMinutes $settings
  $baseDelayMs = ($windowMinutes * 60000.0) / $limit
  $speedFactors = @{ slow = 1.2; normal = 1.0; fast = 0.75 }
  $factor = if ($speedFactors.ContainsKey($speed)) { $speedFactors[$speed] } else { 1.0 }
  $jitter = 0.8 + ((Get-Random -Minimum 0 -Maximum 41) / 100.0)
  $scaledDelayMs = [Math]::Floor($baseDelayMs * $factor * $jitter)
  $delayMs = [Math]::Max($speedDelayMs, $scaledDelayMs)
  return [Math]::Max(1, [int]([Math]::Round($delayMs / 1000.0)))
}

function Rank-ActionsByProgress($configData, $actions) {
  $settings = $configData.settings
  return @($actions | Sort-Object `
    @{ Expression = { (DailyCount $configData $_) / [Math]::Max((ActionLimit $settings $_), 1) }; Ascending = $true }, `
    @{ Expression = { DailyCount $configData $_ }; Ascending = $true })
}

function Pick-NormalAction($configData) {
  $available = @(AvailableNormalActions $configData)
  if ($available.Count -gt 0) {
    $ranked = @(Rank-ActionsByProgress $configData $available)
    return [string]$ranked[0]
  }
  return ''
}

function OrderedAvailableActions($configData) {
  $settings = $configData.settings
  $actions = @(EnabledActions $settings)
  if (AvailableUnfollowAction $configData) { $actions += 'unfollow' }
  return @($actions | Where-Object {
    (DailyCount $configData $_) -lt (ActionLimit $settings $_)
  })
}

function Pick-SequentialAction($configData) {
  $actions = @(OrderedAvailableActions $configData)
  if ($actions.Count -le 0) { return '' }

  $total = 0
  foreach ($action in @('follow','like','comment','unfollow')) {
    $total += (DailyCount $configData $action)
  }

  for ($i = 0; $i -lt $actions.Count; $i++) {
    $candidate = [string]$actions[($total + $i) % $actions.Count]
    if ((DailyCount $configData $candidate) -lt (ActionLimit $configData.settings $candidate)) {
      return $candidate
    }
  }
  return [string]$actions[0]
}

function Pick-Action($configData) {
  $settings = $configData.settings
  $normal = @(AvailableNormalActions $configData)
  $available = @($normal)
  if (AvailableUnfollowAction $configData) { $available += 'unfollow' }

  if ($available.Count -gt 0) {
    if ($settings.action_distribution_mode -eq 'sequential') {
      $picked = [string](Pick-SequentialAction $configData)
    } else {
      $ranked = @(Rank-ActionsByProgress $configData $available)
      $picked = [string]$ranked[0]
    }
    if ($picked -eq 'unfollow') { return 'unfollow' }
    if ($settings.action_mode -eq 'all_per_contact' -and $normal.Count -gt 0) { return 'all_per_contact' }
    return $picked
  }

  return ''
}

function ActionsForTarget($configData, [string]$pickedAction) {
  if ($pickedAction -eq 'all_per_contact') { return @(AvailableNormalActions $configData) }
  return @($pickedAction)
}

function Is-ActionStillAllowed($configData, [string]$action) {
  if (-not $configData.settings.running) { return $false }
  if ((Get-ActionBlockCooldownSeconds) -gt 0) { return $false }
  if ($action -eq 'unfollow') { return [bool](AvailableUnfollowAction $configData) }
  return (@(AvailableNormalActions $configData) -contains $action)
}

function Invoke-AgentAction($ws, [string]$action, [string]$target, $settings, $config = $null, $token = $null) {
  if ($action -eq 'follow') { return Do-Follow $ws $target $settings }
  if ($action -eq 'like') { return Do-Like $ws $target }
  if ($action -eq 'comment') { return Do-Comment $ws $target $settings $config $token }
  if ($action -eq 'unfollow') { return Do-Unfollow $ws $target $settings }
  return @{ ok=$false; reason='acao_invalida' }
}

function Pick-LegacyAction($configData) {
  return Pick-Action $configData
}

function Remove-Diacritics([string]$value) {
  if (-not $value) { return '' }
  $normalized = $value.Normalize([System.Text.NormalizationForm]::FormD)
  $builder = New-Object System.Text.StringBuilder
  foreach ($ch in $normalized.ToCharArray()) {
    $category = [System.Globalization.CharUnicodeInfo]::GetUnicodeCategory($ch)
    if ($category -ne [System.Globalization.UnicodeCategory]::NonSpacingMark) {
      [void]$builder.Append($ch)
    }
  }
  return $builder.ToString()
}

function Clean-Username([string]$value) {
  $clean = Remove-Diacritics ((($value -replace '@','') -replace '#','').Trim().ToLower())
  return ($clean -replace '[^a-z0-9._]', '')
}

function Clean-Hashtag([string]$value) {
  $clean = Remove-Diacritics ((($value -replace '#','').Trim().ToLower()))
  return ($clean -replace '[^a-z0-9_]', '')
}

function Normalize-InstagramPostUrl([string]$value) {
  if (-not $value) { return '' }
  try {
    $uri = [Uri]$value
    $parts = @($uri.AbsolutePath.Split('/') | Where-Object { $_ })
    if ($parts.Count -ge 2 -and ($parts[0] -eq 'p' -or $parts[0] -eq 'reel')) {
      return "https://www.instagram.com/$($parts[0])/$($parts[1])/"
    }
  } catch {}
  return ([string]$value).Split('?')[0].Trim()
}

function Normalize-InstagramPostKey([string]$value) {
  if (-not $value) { return '' }
  try {
    $uri = [Uri]$value
    $parts = @($uri.AbsolutePath.Split('/') | Where-Object { $_ })
    if ($parts.Count -ge 2 -and ($parts[0] -eq 'p' -or $parts[0] -eq 'reel')) {
      return "$($parts[0])/$($parts[1])"
    }
  } catch {}
  return ''
}

function Load-BadPostCache {
  if (-not (Test-Path -LiteralPath $badPostPath)) { return }
  try {
    foreach ($line in @(Get-Content -LiteralPath $badPostPath -Encoding UTF8 -ErrorAction SilentlyContinue)) {
      $url = Normalize-InstagramPostUrl ([string]($line -split '\s+')[0])
      if ($url -and $script:badPostSet.Add($url)) {
        $script:badPostOrder += $url
      }
    }
    if ($script:badPostSet.Count -gt 0) {
      Write-AgentLog "Cache de posts ruins carregado: $($script:badPostSet.Count) URL(s)"
    }
  } catch {
    Write-AgentLog "Nao foi possivel carregar bad-posts.txt: $($_.Exception.Message)"
  }
}

function Add-BadPost([string]$url, [string]$reason) {
  $clean = Normalize-InstagramPostUrl $url
  if (-not $clean) { return }
  if (-not $script:badPostSet.Add($clean)) { return }
  $script:badPostOrder += $clean
  try {
    Add-Content -LiteralPath $badPostPath -Value "$clean $reason" -Encoding UTF8
    if ($script:badPostOrder.Count -gt 300) {
      $script:badPostOrder = @($script:badPostOrder | Select-Object -Last 300)
      Set-Content -LiteralPath $badPostPath -Value $script:badPostOrder -Encoding UTF8
      $script:badPostSet.Clear()
      foreach ($item in $script:badPostOrder) { [void]$script:badPostSet.Add($item) }
    }
  } catch {
    Write-AgentLog "Nao foi possivel salvar post ruim ${clean}: $($_.Exception.Message)"
  }
}

function Is-ReservedTarget([string]$target) {
  $clean = Clean-Username $target
  if (-not $clean) { return $true }
  if ($clean.Length -gt 30) { return $true }
  if ($clean -notmatch '^[a-z0-9._]+$') { return $true }
  $reserved = @(
    'p','reel','reels','stories','explore','accounts','direct','about','developer',
    'challenge','oauth','web','popular','create','notifications','blog','legal',
    'privacy','terms','careers','help','api','locations','instagram','instagramlite',
    'instagram-lite','lite','meta','metaai','meta-ai','threads','business','ads',
    'safety','press','directory','emails','contact','upload','upload-de-contatos-e-não-usuários',
    'upload-de-contatos-e-não-usuários','meta-verified','verified'
  )
  return $reserved -contains $clean
}

function Normalize-SourceList($items, [string]$kind) {
  $clean = @()
  foreach ($item in @($items)) {
    $value = [string]$item
    if ($kind -eq 'hashtag') { $value = Clean-Hashtag $value }
    elseif ($kind -eq 'competitor') { $value = Clean-Username $value }
    else { $value = $value.Trim() }
    if ($value -and -not ($clean -contains $value)) { $clean += $value }
  }
  return $clean
}

function Set-SourceTypeCooldown([string]$type, [int]$minutes = 20) {
  if (-not $type) { return }
  $script:sourceTypeCooldownUntil[$type] = (Get-Date).AddMinutes($minutes)
}

function Is-SourceTypeCoolingDown([string]$type) {
  if (-not $script:sourceTypeCooldownUntil.ContainsKey($type)) { return $false }
  return (Get-Date) -lt $script:sourceTypeCooldownUntil[$type]
}

# v5.8.5: mesmo principio do Is-BenignActionReason/unfollow_repeated_failure,
# aplicado a fonte de coleta - depois de 3 falhas seguidas no MESMO
# concorrente, ele fica de fora da selecao por um tempo, em vez de poder
# ser sorteado de novo na proxima rodada e travar a coleta nele.
function Is-CompetitorCoolingDown([string]$username) {
  if (-not $username) { return $false }
  if ($script:serverRestrictedCompetitors.Contains($username)) { return $true }
  if (-not $script:competitorCooldownUntil.ContainsKey($username)) { return $false }
  return (Get-Date) -lt $script:competitorCooldownUntil[$username]
}

function Sync-RestrictedCompetitorsFromServer([switch]$Force) {
  # v5.9.7: puxa a lista de concorrentes restritos gravada no servidor
  # (individual dessa conta) - assim o Agent tambem pula concorrentes que a
  # Extensao ja descobriu que restringem, e vice-versa. So reconsulta a
  # cada 10min pra nao sobrecarregar a API a cada rodada.
  if (-not $Force -and ((Get-Date) - $script:lastRestrictedSyncAt) -lt [TimeSpan]::FromMinutes(10)) { return }
  $script:lastRestrictedSyncAt = Get-Date
  try {
    $result = ApiGet $script:config $script:token '/api/agents/competitors/restricted'
    $script:serverRestrictedCompetitors = New-Object 'System.Collections.Generic.HashSet[string]'
    foreach ($u in @($result.restricted)) {
      if ($u) { [void]$script:serverRestrictedCompetitors.Add((Clean-Username $u)) }
    }
  } catch {
    # falha de rede/servidor aqui nao deve travar a coleta - so mantem o
    # que ja tinha em cache ate a proxima tentativa.
  }
}

function Register-CompetitorFailure([string]$username, [string]$reason) {
  if (-not $username) { return }
  $count = 1
  if ($script:competitorFailureCounts.ContainsKey($username)) { $count = [int]$script:competitorFailureCounts[$username] + 1 }
  $script:competitorFailureCounts[$username] = $count
  if ($count -ge 3) {
    $script:competitorCooldownUntil[$username] = (Get-Date).AddMinutes(20)
    $script:competitorFailureCounts.Remove($username)
    Write-AgentLog "Concorrente @$username falhou $count vezes seguidas ($reason) - em espera 20min. Seguindo para outra fonte."
  }
}

function Register-CompetitorSuccess([string]$username) {
  if (-not $username) { return }
  if ($script:competitorFailureCounts.ContainsKey($username)) { $script:competitorFailureCounts.Remove($username) }
}

function Register-CompetitorRestricted([string]$username, $settings) {
  # v5.9.5: diferente de Register-CompetitorFailure (que precisa de 3
  # falhas seguidas pra por em espera de 20min), aqui a Instagram ja
  # confirmou explicitamente ("Somente @username pode ver todos os
  # seguidores") que essa conta restringe a lista de seguidores pra
  # qualquer visitante - nao e uma falha temporaria, e uma configuracao
  # da conta. Nao faz sentido esperar 3 tentativas pra reconhecer isso;
  # poe em espera bem mais longa direto na primeira deteccao.
  # v5.9.7: alem do cache local, avisa o servidor - assim a Extensao dessa
  # mesma conta ja nasce sabendo, sem precisar redescobrir sozinha, e o
  # dashboard consegue mostrar o aviso pro cliente.
  # v5.9.11: dias configuraveis pelo cliente no painel (padrao 7, era fixo
  # em 30) - a instabilidade parece mais passageira do que uma restricao
  # fixa da conta.
  if (-not $username) { return }
  $days = 7
  if ($settings -and $settings.restricted_competitor_days) {
    $parsedDays = 0
    if ([int]::TryParse([string]$settings.restricted_competitor_days, [ref]$parsedDays) -and $parsedDays -gt 0) {
      $days = [Math]::Min([Math]::Max($parsedDays, 1), 90)
    }
  }
  $script:competitorCooldownUntil[$username] = (Get-Date).AddDays($days)
  [void]$script:serverRestrictedCompetitors.Add($username)
  if ($script:competitorFailureCounts.ContainsKey($username)) { $script:competitorFailureCounts.Remove($username) }
  Write-AgentLog "Concorrente @$username restringe a lista de seguidores (confirmado pela propria Instagram) - em espera $days dia(s)."
  try {
    ApiPost $script:config $script:token '/api/agents/competitors/restricted' @{ competitor_username = $username; detected_by = 'agent' } | Out-Null
  } catch {
    Write-AgentLog "Nao consegui avisar o servidor sobre @$username restrito (sem conexao?) - segue valendo localmente."
  }
}

function Test-CompetitorFollowersRestricted($ws) {
  $js = @'
(() => {
  const dialog = document.querySelector('[role="dialog"]');
  const scope = dialog || document;
  const text = clean(scope.innerText || scope.textContent || '').replace(/\s+/g, ' ');
  // v5.9.9: alem de "Somente @X pode ver todos os seguidores", a Instagram
  // tambem mostra uma tela vazia com "Sugestoes para voce" dentro do MESMO
  // dialogo (confirmado: achadinhodanina_shop foi coletado assim e nao
  // segue o concorrente de verdade). Restringir a busca ao dialogo nao
  // basta nesse caso, porque as sugestoes ficam dentro dele, nao fora.
  const restricted = text.indexOf('pode ver todos os seguidores') !== -1 ||
    text.indexOf('can see all followers') !== -1 ||
    text.indexOf('sugestoes para voce') !== -1 ||
    text.indexOf('suggested for you') !== -1;
  return JSON.stringify({ restricted });
})()
'@
  $result = Eval-Cdp $ws ($JsCommon + "`n" + $js)
  return [bool]$result.restricted
}

function Get-AvailableSourceTypes($settings) {
  $configured = @($settings.seg_types)
  if ($configured.Count -eq 0 -and $settings.seg_type) { $configured = @($settings.seg_type) }
  if ($configured.Count -eq 0) { $configured = @('hashtag') }

  $available = @()
  foreach ($type in $configured) {
    $type = ([string]$type).Trim().ToLower()
    if ($type -eq 'hashtag' -and @(Normalize-SourceList $settings.hashtags 'hashtag').Count) { $available += 'hashtag' }
    elseif ($type -eq 'competitor' -and @(Normalize-SourceList $settings.competitors 'competitor').Count) { $available += 'competitor' }
    elseif ($type -eq 'location' -and @(Normalize-SourceList $settings.locations 'location').Count) { $available += 'location' }
    elseif ($type -eq 'explore') { $available += 'explore' }
  }

  if ($available.Count -eq 0 -and $configured.Count -eq 0) {
    if (@(Normalize-SourceList $settings.hashtags 'hashtag').Count) { $available += 'hashtag' }
    if (@(Normalize-SourceList $settings.competitors 'competitor').Count) { $available += 'competitor' }
    if (@(Normalize-SourceList $settings.locations 'location').Count) { $available += 'location' }
    $available += 'explore'
  }
  $available = @($available | Select-Object -Unique)
  $notCooling = @($available | Where-Object { -not (Is-SourceTypeCoolingDown $_) })
  if ($notCooling.Count -gt 0) { return $notCooling }
  return @()
}

function Pick-SourceItem([string]$kind, $items, $settings) {
  $list = @(Normalize-SourceList $items $kind)
  if ($list.Count -eq 0) { return '' }
  if ($settings.source_pick_mode -ne 'list_order') {
    return [string]$list[(Get-Random -Minimum 0 -Maximum $list.Count)]
  }
  if (-not $script:sourcePickIndex.ContainsKey($kind)) { $script:sourcePickIndex[$kind] = 0 }
  $current = [int]$script:sourcePickIndex[$kind]
  $value = [string]$list[$current % $list.Count]
  $script:sourcePickIndex[$kind] = ($current + 1) % $list.Count
  return $value
}

function Get-ActiveSourceType($settings) {
  $types = @(Get-AvailableSourceTypes $settings)
  if ($types.Count -eq 0) { return '' }
  return [string]$types[$script:sourceTypeIndex % $types.Count]
}

function Advance-SourceType($settings) {
  $types = @(Get-AvailableSourceTypes $settings)
  if ($types.Count -gt 1) {
    $script:sourceTypeIndex = ($script:sourceTypeIndex + 1) % $types.Count
  }
}

function SourceUrl($settings) {
  $seg = Get-ActiveSourceType $settings
  if (-not $seg) { return @{ url=''; type='none'; label='Nenhuma fonte ativa configurada' } }
  if ($seg -eq 'competitor') {
    # v5.8.5: tira da selecao os concorrentes que ja falharam 3x seguidas
    # recentemente (Is-CompetitorCoolingDown) antes de escolher - assim um
    # concorrente com problema especifico (perfil privado, etc) nao volta a
    # ser sorteado toda hora e trava a coleta.
    # v5.9.7: antes de filtrar, sincroniza com o servidor (throttle de
    # 10min embutido) pra tambem respeitar o que a Extensao ja descobriu.
    Sync-RestrictedCompetitorsFromServer
    $allCompetitors = @(Normalize-SourceList $settings.competitors 'competitor')
    $eligibleCompetitors = @($allCompetitors | Where-Object { -not (Is-CompetitorCoolingDown $_) })
    $pick = Pick-SourceItem 'competitor' $eligibleCompetitors $settings
    # v5.8.4: antes navegava direto pra /username/followers/ (deep link) -
    # isso e um padrao que usuario real nunca faz (ninguem digita/carrega
    # essa URL direto, sempre chega la clicando no numero de seguidores de
    # dentro do perfil ja carregado). Isso pode ter virado justamente o
    # sinal que o Instagram passou a rejeitar (1000+ ocorrencias de
    # followers_dialog_not_open). Agora carrega o perfil normal - o clique
    # de verdade no link acontece em Open-CompetitorFollowersDialog.
    if ($pick) { return @{ url="https://www.instagram.com/$pick/"; type='competitor'; label="@$pick" } }
    return @{ url=''; type='competitor'; label='Todos os concorrentes em espera' }
  }
  if ($seg -eq 'location') {
    $pick = Pick-SourceItem 'location' $settings.locations $settings
    if ($pick) { return @{ url="https://www.instagram.com/explore/search/keyword/?q=$([Uri]::EscapeDataString($pick))"; type='location'; label=$pick } }
    return @{ url=''; type='location'; label='Nenhuma localizacao configurada' }
  }
  if ($seg -eq 'hashtag') {
    $tag = Pick-SourceItem 'hashtag' $settings.hashtags $settings
    if (-not $tag) { return @{ url=''; type='hashtag'; label='Nenhuma hashtag configurada' } }
    return @{ url="https://www.instagram.com/explore/search/keyword/?q=%23$([Uri]::EscapeDataString($tag))"; type='hashtag'; label="#$tag" }
  }
  if ($seg -eq 'explore') { return @{ url='https://www.instagram.com/explore/'; type='explore'; label='Explorar' } }
  return @{ url=''; type='none'; label='Nenhuma fonte ativa configurada' }
}

function Recover-InstagramPage($ws, $settings, [string]$reason) {
  $source = SourceUrl $settings
  Write-AgentLog "Recuperando navegador após $reason. Limpando página de erro e voltando para fonte."
  try {
    Navigate-Cdp $ws 'https://www.instagram.com/' 5
    Eval-Cdp $ws "(() => { try { window.stop(); location.href = '$($source.url)'; return { ok:true }; } catch(e) { return { ok:false, reason:String(e && e.message || e) }; } })()" | Out-Null
    Start-Sleep -Seconds 7
    Dismiss-InstagramBlockingDialogs $ws | Out-Null
    $state = Eval-Cdp $ws "(() => { const raw = (document.body && document.body.innerText || '').toLowerCase(); const text = raw.normalize ? raw.normalize('NFD').replace(/[\u0300-\u036f]/g, '') : raw; return JSON.stringify({ ok:true, href:location.href, unavailable:text.includes('está página não está disponivel') || text.includes('this page isn') }); })()"
    if ($state.unavailable) {
      Navigate-Cdp $ws 'https://www.instagram.com/' 6
      Start-Sleep -Seconds 2
    }
  } catch {
    Write-AgentLog "Falha ao recuperar navegador: $($_.Exception.Message)"
    try { Navigate-Cdp $ws 'https://www.instagram.com/' 6 } catch {}
  }
}

function Wait-InstagramRateLimitCooldown($configData, [string]$detail) {
  $script:targetQueue = @()
  $script:consecutiveRateLimitHits++
  # Backoff progressivo: 10, 20, 40, 60, 60... minutos. Um 429 logo apos os
  # 10 minutos de espera geralmente significa que o bloqueio do Instagram
  # dura mais que isso; continuar tentando toda vez com o mesmo intervalo
  # fixo so faz o Agent bater 429 de novo e de novo, gastando a janela do
  # dia sem nunca completar o cooldown de verdade.
  $waitMinutes = [Math]::Min(10 * [Math]::Pow(2, $script:consecutiveRateLimitHits - 1), 60)
  $waitSeconds = [int]($waitMinutes * 60)
  $attemptInfo = "tentativa $($script:consecutiveRateLimitHits), pausando $([int]$waitMinutes) minutos"
  $message = if ($detail) { "$detail ($attemptInfo)" } else { "Instagram retornou 429. Pausando $([int]$waitMinutes) minutos ($attemptInfo) e reiniciando o navegador do Agent." }
  Write-AgentLog $message
  try { Stop-AgentBrowser } catch {}
  if ($configData) { Print-Dashboard $configData 'Aguardando' $message }
  Start-Sleep -Seconds $waitSeconds
}

# v5.8.12: abre o dialogo de seguidores clicando no controle visivel do
# perfil ja carregado. O Instagram nem sempre expoe esse controle como
# a[href*="/followers/"], entao tambem procuramos por texto/role clicavel.
function Open-CompetitorFollowersDialog($ws) {
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
  $followersHelper = @'
function followersScope() {
  return document.querySelector('main header') || document.querySelector('header') || document.querySelector('main') || document;
}

function followersPageState() {
  const text = clean(document.body ? (document.body.innerText || '') : '');
  if (typeof looksBlockedOnPage === 'function' && looksBlockedOnPage()) return 'instagram_restriction';
  if (text.indexOf('esta conta e privada') !== -1 || text.indexOf('essa conta e privada') !== -1 || text.indexOf('este perfil e privado') !== -1 || text.indexOf('esse perfil e privado') !== -1 || text.indexOf('this account is private') !== -1 || text.indexOf('this profile is private') !== -1) return 'private_profile';
  if (text.indexOf('entre para ver') !== -1 || text.indexOf('faca login') !== -1 || text.indexOf('log in to see') !== -1) return 'login_required';
  if (text.indexOf('pagina nao esta disponivel') !== -1 || text.indexOf('page is not available') !== -1 || text.indexOf('usuario nao encontrado') !== -1 || text.indexOf('user not found') !== -1) return 'profile_unavailable';
  return '';
}

function isFollowersText(value) {
  const text = clean(value).replace(/\s+/g, ' ');
  return text.indexOf('seguidores') !== -1 || text.indexOf('seguidor') !== -1 || text.indexOf('followers') !== -1 || text.indexOf('follower') !== -1;
}

function shortText(el) {
  return clean((el && (el.innerText || el.textContent || el.getAttribute('aria-label') || el.getAttribute('title'))) || '').replace(/\s+/g, ' ').slice(0, 160);
}

function clickableFollowerTarget(el) {
  if (!el) return null;
  const direct = el.closest ? el.closest('a,button,[role="link"],[role="button"]') : null;
  let target = direct || el;
  if (direct) {
    const drect = direct.getBoundingClientRect();
    const dtext = shortText(direct);
    if (drect.width > 360 || drect.height > 120 || dtext.length > 120) target = el;
  }
  return isVisible(target) ? target : null;
}

function followersDiagnostics() {
  const scope = followersScope();
  const header = document.querySelector('main header') || document.querySelector('header');
  const sample = Array.prototype.slice.call(scope.querySelectorAll('a,button,[role="link"],[role="button"],span,div,li'))
    .filter(function (el) { return isVisible(el) && isFollowersText(shortText(el)); })
    .slice(0, 8)
    .map(function (el) {
      return { tag: el.tagName, role: el.getAttribute('role') || '', href: el.getAttribute('href') || '', text: shortText(el).slice(0, 80) };
    });
  return {
    url: location.href,
    title: document.title || '',
    headerText: clean(header ? (header.innerText || header.textContent || '') : '').replace(/\s+/g, ' ').slice(0, 220),
    followersLinks: document.querySelectorAll('a[href*="/followers/"]').length,
    candidates: sample
  };
}

function findFollowersControl() {
  const scope = followersScope();
  const candidates = [];
  function add(el) {
    const target = clickableFollowerTarget(el);
    if (!target) return;
    if (candidates.find(function (item) { return item.el === target; })) return;
    const rect = target.getBoundingClientRect();
    const text = shortText(target);
    if (rect.width > 420 || rect.height > 140 || !text || text.length > 140) return;
    let score = 1000 + (rect.width * rect.height / 100);
    const href = (target.getAttribute && target.getAttribute('href')) || '';
    if (href.indexOf('/followers/') !== -1) score -= 600;
    if (/[0-9]/.test(text)) score -= 160;
    if (text.indexOf('seguidores') !== -1 || text.indexOf('followers') !== -1) score -= 120;
    candidates.push({ el: target, score: score });
  }

  Array.prototype.slice.call(scope.querySelectorAll('a[href*="/followers/"]')).forEach(add);
  Array.prototype.slice.call(scope.querySelectorAll('a,button,[role="link"],[role="button"],span,div,li')).slice(0, 700).forEach(function (el) {
    if (!isVisible(el)) return;
    const text = shortText(el);
    if (!text || text.length > 120 || !isFollowersText(text)) return;
    add(el);
  });

  candidates.sort(function (a, b) { return a.score - b.score; });
  const picked = candidates.find(function (item) {
    const el = item.el;
    const rect = el.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  });
  return picked ? picked.el : null;
}
'@
  $locateJs = @'
(async () => {
  try {
    const end = Date.now() + 7000;
    while (Date.now() < end) {
      const state = followersPageState();
      if (state) return JSON.stringify({ ok:false, reason:state, detail:followersDiagnostics() });
      const target = findFollowersControl();
      if (target) {
        markClickTarget(target);
        return JSON.stringify({ ok:true });
      }
      await sleep(300);
    }
    return JSON.stringify({ ok:false, reason:'followers_link_not_found', detail:followersDiagnostics() });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $located = Eval-Cdp $ws ($JsCommon + "`n" + $followersHelper + "`n" + $locateJs)
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
  if (-not $located.ok) { return $located }

  Start-Sleep -Milliseconds (Get-Random -Minimum 250 -Maximum 850)
  $point = Get-FreshClickPoint $ws
  if (-not $point.ok) { return $point }
  Invoke-CdpHumanClick $ws ([double]$point.x) ([double]$point.y)

  $confirmPhaseTemplate = @'
(async () => {
  try {
    const end = Date.now() + __TIMEOUT__;
    while (Date.now() < end) {
      if (document.querySelector('[role="dialog"]')) return JSON.stringify({ confirmed:true });
      await sleep(250);
    }
    const stillThere = !!findFollowersControl();
    return JSON.stringify({ confirmed:false, unchanged: stillThere });
  } catch (err) {
    return JSON.stringify({ confirmed:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $relocateJsTemplate = @'
(async () => {
  try {
    const target = findFollowersControl();
    if (!target) return JSON.stringify({ ok:false, reason:'followers_link_not_found', detail:followersDiagnostics() });
    markClickTarget(target);
    return JSON.stringify({ ok:true });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $diagJsTemplate = @'
(async () => {
  try {
    return JSON.stringify({ detail: { looksBlocked: looksBlockedOnPage(), followers: followersDiagnostics() } });
  } catch (err) {
    return JSON.stringify({ reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $confirmPhaseTemplate = $followersHelper + "`n" + $confirmPhaseTemplate
  $relocateJsTemplate = $followersHelper + "`n" + $relocateJsTemplate
  $diagJsTemplate = $followersHelper + "`n" + $diagJsTemplate
  $result = Invoke-ClickConfirmWithRetry $ws $confirmPhaseTemplate $relocateJsTemplate $diagJsTemplate 'followers_dialog_not_open' 3000 5000
  if ($result.ok) { return $result }

  if ($result.reason -eq 'followers_dialog_not_open' -and $result.detail -and [bool]$result.detail.safeToRetry) {
    $fallbackJs = @'
(async () => {
  try {
    const target = findFollowersControl();
    if (!target) return JSON.stringify({ confirmed:false, method:'dom_click_followers', reason:'followers_link_not_found', detail:followersDiagnostics() });
    if (typeof target.focus === 'function') target.focus();
    humanClick(target);
    const end = Date.now() + 4500;
    while (Date.now() < end) {
      if (document.querySelector('[role="dialog"]')) return JSON.stringify({ confirmed:true, method:'dom_click_followers' });
      await sleep(250);
    }
    return JSON.stringify({ confirmed:false, method:'dom_click_followers', reason:'not_confirmed_after_dom_click' });
  } catch (err) {
    return JSON.stringify({ confirmed:false, method:'dom_click_followers', reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
    $fallback = Eval-Cdp $ws ($JsCommon + "`n" + $followersHelper + "`n" + $fallbackJs)
    if ($fallback.confirmed) { return @{ ok = $true; fallback = $fallback.method } }
    $detail = $result.detail
    if ($null -eq $detail) { $detail = [PSCustomObject]@{} }
    $detail | Add-Member -NotePropertyName 'followersDialogFallback' -NotePropertyValue $fallback -Force
    $result.detail = $detail
  }
  return $result
}

function Escape-MutualOnlyFollowersView($ws) {
  # v5.9.0/v5.9.2: o Instagram por vezes abre o dialogo de seguidores numa
  # sub-view restrita ("seguidores em comum" / mutualOnly), mostrando so 1-2
  # contas com um link "Ver todos os seguidores" embaixo. Sem clicar nesse
  # link a coleta so ve essa lista minuscula (sempre as mesmas contas)
  # achando que e a lista completa.
  #
  # v5.9.2: o link nao e um <a>/<button> semantico (por isso a v5.9.1 nunca
  # achava, so via o botao "Fechar" - confirmado em log de producao). Alem
  # disso, clicar sozinho nao basta: depois do clique a lista real demora
  # alguns segundos pra comecar a carregar E precisa rolar pra baixo
  # repetidas vezes pra ela ir aparecendo (confirmado manualmente por
  # Marcelo). Agora faz: clique com selector mais amplo -> espera mais longa
  # -> varias rodadas de rolar+esperar ate o numero de links parar de
  # crescer (nao so uma rolada unica).
  $checkJs = @'
(() => {
  const isMutualOnly = location.pathname.indexOf('/followers/mutualOnly') !== -1;
  // v5.9.3: nao restringe mais a busca ao primeiro [role="dialog"] do DOM -
  // logs de producao mostraram que "ver todos os seguidores" nunca aparecia
  // entre os candidatos, mesmo em varias tentativas ao longo do dia. Se
  // houver mais de um elemento com role="dialog" na pagina ao mesmo tempo
  // (ex: resto do modal de notificacoes), o querySelector (que pega so o
  // primeiro) pode escopar o dialogo errado. isVisible() ja filtra o que
  // realmente esta renderizado, entao buscar o documento inteiro e seguro.
  const scope = document;
  const isSeeAllLink = (text) => text.indexOf('ver todos os seguidores') !== -1 || text.indexOf('see all followers') !== -1 || (text.indexOf('ver todos') !== -1 && text.indexOf('seguidor') !== -1);
  let hasLink = false;
  for (const el of scope.querySelectorAll('a,button,[role="button"],[role="link"],span,div,li')) {
    if (!isVisible(el)) continue;
    const text = clean(el.innerText || el.textContent || '').replace(/\s+/g, ' ');
    if (text.length > 80) continue;
    if (isSeeAllLink(text)) { hasLink = true; break; }
  }
  return JSON.stringify({ isMutualOnly, hasLink, url: location.href });
})()
'@
  $state = Eval-Cdp $ws ($JsCommon + "`n" + $checkJs)
  # v5.9.16: se estiver na sub-view restrita mas ainda nao achou o link de
  # primeira, da mais uma ou duas chances antes de desistir - confirmado em
  # producao (via a extensao, mesmo codigo) que o dialogo por vezes ainda
  # esta vazio ("Textos visiveis: seguidores | fechar") nesse instante
  # exato, antes do conteudo real (lista ou link) terminar de renderizar.
  $retryCount = 0
  while ($state.isMutualOnly -and -not $state.hasLink -and $retryCount -lt 2) {
    Start-Sleep -Milliseconds (1200 + (Get-Random -Maximum 800))
    $state = Eval-Cdp $ws ($JsCommon + "`n" + $checkJs)
    $retryCount++
  }
  if (-not $state.isMutualOnly -and -not $state.hasLink) { return @{ ok = $true; skipped = $true } }

  Write-AgentLog "Seguidores em sub-view restrita (mutualOnly) detectada - clicando em 'Ver todos os seguidores'."
  $clickJs = @'
(() => {
  const scope = document;
  const isSeeAllLink = (text) => text.indexOf('ver todos os seguidores') !== -1 || text.indexOf('see all followers') !== -1 || (text.indexOf('ver todos') !== -1 && text.indexOf('seguidor') !== -1);
  let best = null;
  let bestArea = Infinity;
  for (const el of scope.querySelectorAll('a,button,[role="button"],[role="link"],span,div,li')) {
    if (!isVisible(el)) continue;
    const text = clean(el.innerText || el.textContent || '').replace(/\s+/g, ' ');
    if (!text || text.length > 80 || !isSeeAllLink(text)) continue;
    const rect = el.getBoundingClientRect();
    if (rect.height > 80) continue;
    const area = rect.width * rect.height;
    if (area > 0 && area < bestArea) { bestArea = area; best = el; }
  }
  if (best) {
    markClickTarget(best);
    return JSON.stringify({ ok:true });
  }
  return JSON.stringify({ ok:false, reason:'see_all_link_not_found', sample: Array.prototype.slice.call(scope.querySelectorAll('a,button,[role="button"],[role="link"],span,div,li')).filter(isVisible).map(function(e){return clean(e.innerText||e.textContent||'').replace(/\s+/g,' ').slice(0,60);}).filter(function(t){return t && t.length < 80;}).slice(0,15), dialogCount: document.querySelectorAll('[role="dialog"]').length });
})()
'@
  $located = Eval-Cdp $ws ($JsCommon + "`n" + $clickJs)
  if (-not $located.ok) {
    if ($located.sample) { Write-AgentLog "Link 'Ver todos os seguidores' nao encontrado (dialogos na pagina: $($located.dialogCount)). Textos visiveis: $($located.sample -join ' | ')" }
    return @{ ok = $false; reason = 'see_all_link_not_found' }
  }

  Start-Sleep -Milliseconds (Get-Random -Minimum 250 -Maximum 700)
  $point = Get-FreshClickPoint $ws
  if (-not $point.ok) { return @{ ok = $false; reason = 'see_all_click_intercepted' } }
  Invoke-CdpHumanClick $ws ([double]$point.x) ([double]$point.y)

  # Depois do clique, a lista real demora alguns segundos pra comecar a
  # carregar - espera ate 10s (em vez dos 5s da v5.9.1) pela URL sair do
  # mutualOnly.
  $waitJs = @'
(async () => {
  const end = Date.now() + 10000;
  while (Date.now() < end) {
    if (location.pathname.indexOf('/followers/mutualOnly') === -1) return JSON.stringify({ ok:true });
    await sleep(300);
  }
  return JSON.stringify({ ok:false, reason:'still_mutual_only' });
})()
'@
  $waited = Eval-Cdp $ws ($JsCommon + "`n" + $waitJs)
  if (-not $waited.ok) { return @{ ok = $false; reason = 'mutual_only_not_resolved' } }
  Start-Sleep -Milliseconds (Get-Random -Minimum 500 -Maximum 900)

  # Mesmo depois de sair do mutualOnly, a lista completa so vai aparecendo
  # aos poucos conforme rola pra baixo - uma rolada unica nao basta. Rola e
  # espera em varias rodadas ate o numero de links parar de crescer (2
  # rodadas seguidas sem novidade) ou bater um teto de seguranca.
  $scrollLoadJs = @'
(async () => {
  const root = document;
  const countLinks = () => root.querySelectorAll('a[href]').length;
  const scrollable = () => {
    const candidates = Array.prototype.slice.call(root.querySelectorAll('[role="dialog"], div'))
      .filter(function (el) { return el.scrollHeight > el.clientHeight + 80; })
      .sort(function (a, b) { return (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight); });
    return candidates[0] || null;
  };
  let lastCount = countLinks();
  let stableRounds = 0;
  const maxRounds = 6;
  for (let round = 0; round < maxRounds; round += 1) {
    const el = scrollable();
    const stepCount = 3 + Math.floor(Math.random() * 2);
    const totalPx = 500 + Math.floor(Math.random() * 400);
    const perStep = totalPx / stepCount;
    for (let i = 0; i < stepCount; i += 1) {
      if (el) el.scrollTop = Math.min(el.scrollHeight, el.scrollTop + perStep);
      else window.scrollBy(0, perStep);
      await sleep(180 + Math.floor(Math.random() * 220));
    }
    await sleep(1000 + Math.floor(Math.random() * 700));
    const count = countLinks();
    if (count <= lastCount) {
      stableRounds += 1;
      if (stableRounds >= 2) break;
    } else {
      stableRounds = 0;
    }
    lastCount = count;
  }
  return JSON.stringify({ ok:true, linkCount: lastCount });
})()
'@
  Eval-Cdp $ws ($JsCommon + "`n" + $scrollLoadJs) | Out-Null
  return @{ ok = $true }
}

function Collect-Targets($ws, $settings) {
  $source = SourceUrl $settings
  $script:lastTargetCollectionReason = ''
  $script:lastSourceInfo = @{ type=[string]$source.type; label=[string]$source.label; url=[string]$source.url }
  Write-AgentLog "Coletando alvos via $($source.type): $($source.label) url=$($source.url)"
  if (-not $source.url) {
    $script:lastTargetCollectionReason = 'no_configured_source_available'
    Write-AgentLog "Coleta pulada: nenhuma fonte ativa disponivel para a configuracao atual ($($source.label))."
    return @()
  }
  Navigate-Cdp $ws ([string]$source.url) 8
  $own = Clean-Username $settings.account_username
  $sourceAccount = ''
  if ($source.type -eq 'competitor') { $sourceAccount = Clean-Username (([string]$source.label).TrimStart('@')) }
  $sourceType = [string]$source.type

  if ($sourceType -eq 'competitor') {
    $dialogResult = Open-CompetitorFollowersDialog $ws
    if (-not $dialogResult.ok) {
      $reason = if ($dialogResult.reason) { $dialogResult.reason } else { 'followers_dialog_not_open' }
      $script:lastTargetCollectionReason = $reason
      Write-AgentLog "Coleta falhou via $($source.type): $($source.label) reason=$reason"
      if ($reason -eq 'instagram_429' -or $reason -eq 'instagram_restriction') {
        Set-SourceTypeCooldown ([string]$source.type)
        Write-AgentLog "Fonte $($source.type) em espera 20min ($reason durante coleta)."
      } elseif ($reason -eq 'private_profile') {
        # v5.8.5: perfil privado e uma condicao permanente (nao vai virar
        # publico entre uma rodada e outra) - poe em espera direto, sem
        # gastar 2 tentativas a mais tentando de novo pra nada.
        $script:competitorCooldownUntil[$sourceAccount] = (Get-Date).AddMinutes(20)
        Write-AgentLog "Concorrente @$sourceAccount e privado - em espera 20min."
      } else {
        Register-CompetitorFailure $sourceAccount $reason
      }
      Advance-SourceType $settings
      return @()
    }
    # v5.9.5: a Instagram por vezes confirma explicitamente que o proprio
    # concorrente restringe quem ve a lista completa de seguidores
    # ("Somente @username pode ver todos os seguidores"). Isso e uma
    # configuracao da conta, nao uma falha temporaria - poe em espera
    # longa (24h) pra nao gastar tentativa nela de novo tao cedo. A
    # coleta desta rodada segue normal (a lista parcial que aparece ali
    # embaixo as vezes ainda traz alvos reais, entao nao descarta).
    if (Test-CompetitorFollowersRestricted $ws) {
      Register-CompetitorRestricted $sourceAccount $settings
    }
    # v5.9.0: escapa da sub-view restrita "mutualOnly" antes de coletar -
    # nao trata falha aqui como erro fatal (segue com o que tiver na tela
    # mesmo que o link "ver todos" nao seja encontrado/clicado a tempo).
    $expandResult = Escape-MutualOnlyFollowersView $ws
    if (-not $expandResult.ok) {
      Write-AgentLog "Nao consegui expandir para lista completa de seguidores ($($expandResult.reason)) - seguindo com o que estiver visivel."
    }
  }
  # v5.9.8: pra concorrente, monta a lista de quem ja teve TODAS as acoes
  # habilitadas concluidas - o scan() vai ignorar essas pessoas na hora de
  # decidir se ja achou candidato "util" o suficiente pra parar de rolar.
  # Sem isso, se as primeiras pessoas visiveis da lista ja tiverem sido
  # totalmente processadas, o scan parava ali mesmo sem rolar mais,
  # trazendo sempre os mesmos 1-2 nomes ja feitos (candidatos=1/2 que a
  # gente via nos logs).
  $exhaustedJson = '[]'
  if ($sourceType -eq 'competitor') {
    $actionsEnabled = @($settings.actions_enabled)
    $union = New-Object 'System.Collections.Generic.HashSet[string]'
    if ($script:followedSet) { foreach ($u in $script:followedSet) { [void]$union.Add($u) } }
    if ($script:likedSet) { foreach ($u in $script:likedSet) { [void]$union.Add($u) } }
    if ($script:commentedSet) { foreach ($u in $script:commentedSet) { [void]$union.Add($u) } }
    $exhausted = @()
    foreach ($u in $union) {
      $doneAll = $true
      if ($actionsEnabled -contains 'follow' -and -not $script:followedSet.Contains($u)) { $doneAll = $false }
      if ($actionsEnabled -contains 'like' -and -not $script:likedSet.Contains($u)) { $doneAll = $false }
      if ($actionsEnabled -contains 'comment' -and -not $script:commentedSet.Contains($u)) { $doneAll = $false }
      if ($doneAll) { $exhausted += $u }
    }
    if ($exhausted.Count -gt 0) { $exhaustedJson = ($exhausted | ConvertTo-Json -Compress) }
    if ($exhausted.Count -eq 1) { $exhaustedJson = "[$exhaustedJson]" }
  }
  $js = @"
(async () => {
  const own = '$own';
  const sourceAccount = '$sourceAccount';
  const sourceType = '$sourceType';
  const exhaustedSet = new Set(JSON.parse('$exhaustedJson'));
  const reserved = new Set([
    'p','reel','reels','stories','explore','accounts','direct','about','developer',
    'challenge','oauth','web','popular','create','notifications','blog','legal',
    'privacy','terms','careers','help','api','locations','instagram','instagramlite',
    'instagram-lite','lite','meta','metaai','meta-ai','threads','business','ads',
    'safety','press','directory','emails','contact','upload','upload-de-contatos-e-não-usuários',
    'meta-verified','verified'
  ]);
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const clean = value => String(value || '')
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .trim()
    .toLowerCase();
  const isVisible = el => {
    if (!el) return false;
    const style = getComputedStyle(el);
    const rect = el.getBoundingClientRect();
    return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
  };
  const isRateLimitedPage = () => {
    const text = clean((document.title || '') + ' ' + (document.body ? document.body.innerText || '' : ''));
    return text.includes('http error 429') || text.includes('error 429') || text.includes('too many requests') || text.includes('muitas solicitacoes');
  };
  // Rola em alguns passos pequenos com pausa variavel entre eles, em vez de
  // um salto unico - mais parecido com scroll de mouse/touchpad real.
  const incrementalScroll = async (el, totalPx) => {
    const stepCount = 3 + Math.floor(Math.random() * 2);
    const perStep = totalPx / stepCount;
    for (let i = 0; i < stepCount; i += 1) {
      if (el) el.scrollTop = Math.min(el.scrollHeight, el.scrollTop + perStep);
      else window.scrollBy(0, perStep);
      await sleep(180 + Math.floor(Math.random() * 220));
    }
  };
  const scan = () => {
    const root = sourceType === 'competitor' ? (document.querySelector('[role="dialog"]') || document) : document;
    // v5.9.9: quando o dialogo mostra "Sugestoes para voce" em vez da
    // lista real (tela vazia de seguidores), os links ali sao sugestoes
    // genericas da Instagram, nao seguidores de verdade do concorrente -
    // confirmado que gente coletada assim nao aparece na busca da lista
    // real. Nesse caso especifico nao escaneia nada (diferente do caso
    // "Somente @X pode ver", onde a lista parcial abaixo ainda e real).
    if (sourceType === 'competitor') {
      const rootText = clean(root.innerText || root.textContent || '').replace(/\s+/g, ' ');
      if (rootText.indexOf('sugestoes para voce') !== -1 || rootText.indexOf('suggested for you') !== -1) {
        return { ok:true, targets: [], postLinks: [], url: location.href };
      }
    }
    const targets = [];
    const postLinks = [];
    for (const a of root.querySelectorAll('a[href]')) {
      try {
        const url = new URL(a.href, location.href);
        if (!url.hostname.includes('instagram.com')) continue;
        const parts = url.pathname.split('/').filter(Boolean);
        const first = parts[0] || '';
        if ((first === 'p' || first === 'reel') && parts[1]) {
          const href = url.href.split('?')[0];
          if (!postLinks.includes(href)) postLinks.push(href);
          continue;
        }
        if (parts.length !== 1) continue;
        const clean2 = first.replace('@','').toLowerCase();
        if (!clean2 || clean2 === own || clean2 === sourceAccount || clean2.length > 30 || reserved.has(clean2)) continue;
        if (!/^[a-z0-9._]+$/.test(clean2)) continue;
        if (!targets.includes(clean2)) targets.push(clean2);
      } catch {}
    }
    return { ok:true, targets: targets.slice(0, 40), postLinks: postLinks.slice(0, 18), url: location.href };
  };
  const hasUsefulTarget = (result) => result.targets.some(t => !exhaustedSet.has(t));
  try {
    if (isRateLimitedPage()) return JSON.stringify({ ok:false, targets:[], postLinks:[], reason:'instagram_429' });
    let result = scan();
    if (sourceType === 'competitor') {
      // v5.9.8: continua rolando enquanto so achar gente ja totalmente
      // processada, ate achar alguem com acao pendente ou bater o teto de
      // seguranca (6 rodadas) - antes parava na primeira leva de nomes,
      // mesmo que todos ja tivessem sido seguidos/curtidos/comentados.
      let round = 0;
      while (!hasUsefulTarget(result) && round < 6) {
        const root = document.querySelector('[role="dialog"]') || document;
        const scrollables = Array.from(root.querySelectorAll('[role="dialog"], div'))
          .filter(el => el.scrollHeight > el.clientHeight + 80)
          .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight));
        const scrollTarget = 650 + Math.floor(Math.random() * 500);
        if (scrollables[0]) await incrementalScroll(scrollables[0], scrollTarget);
        else await incrementalScroll(null, Math.max(document.body.scrollHeight * 0.35, 700));
        await sleep(900 + Math.floor(Math.random() * 900));
        const next = scan();
        if (next.targets.length <= result.targets.length && round > 0) break; // parou de crescer, nao adianta insistir
        result = next;
        round += 1;
      }
      return JSON.stringify(result);
    }
    if (result.targets.length || result.postLinks.length >= 6) return JSON.stringify(result);
    const root = document;
    const scrollables = Array.from(root.querySelectorAll('[role="dialog"], div'))
      .filter(el => el.scrollHeight > el.clientHeight + 80)
      .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight));
    const scrollTarget = 650 + Math.floor(Math.random() * 500);
    if (scrollables[0]) await incrementalScroll(scrollables[0], scrollTarget);
    else await incrementalScroll(null, Math.max(document.body.scrollHeight * 0.35, 700));
    await sleep(900 + Math.floor(Math.random() * 900));
    return JSON.stringify(scan());
  } catch (err) {
    return JSON.stringify({ ok:false, targets:[], postLinks:[], reason:'collect_exception', detail:String(err && err.message || err) });
  }
})()
"@
  $res = Eval-Cdp $ws $js
  if (-not $res.ok -and $res.reason) {
    $script:lastTargetCollectionReason = [string]$res.reason
    Write-AgentLog "Coleta falhou via $($source.type): $($source.label) reason=$($script:lastTargetCollectionReason)"
    if ($script:lastTargetCollectionReason -eq 'instagram_429' -or $script:lastTargetCollectionReason -eq 'instagram_restriction') {
      Set-SourceTypeCooldown ([string]$source.type)
      Write-AgentLog "Fonte $($source.type) em espera 20min ($($script:lastTargetCollectionReason) durante coleta)."
    } elseif ($source.type -eq 'competitor') {
      Register-CompetitorFailure $sourceAccount $script:lastTargetCollectionReason
    }
    Advance-SourceType $settings
    return @()
  }
  $targets = @()
  if ($res.ok -and $res.targets) {
    $targets = @($res.targets | ForEach-Object { Clean-Username $_ } | Where-Object { $_ -and -not (Is-ReservedTarget $_) } | Select-Object -Unique)
    if ($targets.Count -gt 0) {
      if ($source.type -eq 'competitor') { Register-CompetitorSuccess $sourceAccount }
      Advance-SourceType $settings
      return $targets
    }
  }

  if ($source.type -eq 'competitor') {
    $script:lastTargetCollectionReason = 'no_followers_collected'
    Write-AgentLog "Nenhum seguidor coletado do concorrente $($source.label). Nao vou cair em posts desse perfil."
    Register-CompetitorFailure $sourceAccount 'no_followers_collected'
    Advance-SourceType $settings
    return @()
  }

  $postLinks = @()
  if ($res.ok -and $res.postLinks) {
    $postLinks = @($res.postLinks |
      ForEach-Object { Normalize-InstagramPostUrl ([string]$_) } |
      Where-Object { $_ -and -not $script:badPostSet.Contains($_) } |
      Select-Object -Unique -First 12)
  }
  if ($postLinks.Count -eq 0) {
    Write-AgentLog "Nenhum post novo aproveitavel via $($source.type): $($source.label). Posts ruins na sessão=$($script:badPostSet.Count)"
    Advance-SourceType $settings
    return @()
  }

  $authorJs = @"
(() => {
  const own = '$own';
  const reserved = new Set([
    'p','reel','reels','stories','explore','accounts','direct','about','developer',
    'challenge','oauth','web','popular','create','notifications','blog','legal',
    'privacy','terms','careers','help','api','locations','instagram','instagramlite',
    'instagram-lite','lite','meta','metaai','meta-ai','threads','business','ads',
    'safety','press','directory','emails','contact','upload','upload-de-contatos-e-não-usuários',
    'meta-verified','verified'
  ]);
  const body = document.body.innerText || '';
  if (/HTTP ERROR 429|Esta p.gina n.o est. funcionando|This page isn.t working/i.test(body)) {
    return JSON.stringify({ ok:false, target:'', reason:'instagram_429' });
  }
  if (/p.gina n.o est. dispon.vel|This page isn't available|Sorry, this page isn't available/i.test(body)) {
    return JSON.stringify({ ok:false, target:'', reason:'post_unavailable' });
  }
  const scope = document.querySelector('article') || document;
  for (const a of scope.querySelectorAll('header a[href], a[role="link"][href], a[href]')) {
    try {
      const url = new URL(a.href, location.href);
      if (!url.hostname.includes('instagram.com')) continue;
      const parts = url.pathname.split('/').filter(Boolean);
      if (parts.length !== 1) continue;
      const clean = (parts[0] || '').replace('@','').toLowerCase();
      if (!clean || clean === own || clean.length > 30 || reserved.has(clean)) continue;
      if (!/^[a-z0-9._]+$/.test(clean)) continue;
      return JSON.stringify({ ok:true, target:clean });
    } catch {}
  }
  return JSON.stringify({ ok:false, target:'', reason:'author_not_found' });
})()
"@
  foreach ($post in $postLinks) {
    $postUrl = Normalize-InstagramPostUrl ([string]$post)
    if (-not $postUrl -or $script:badPostSet.Contains($postUrl)) { continue }
    try {
      Write-AgentLog "Testando post via $($source.type): $($source.label) post=$postUrl"
      Navigate-Cdp $ws $postUrl 5
      $author = Eval-Cdp $ws $authorJs
      if ($author.ok -and $author.target) {
        $clean = Clean-Username $author.target
        if ($clean -and -not (Is-ReservedTarget $clean) -and -not ($targets -contains $clean)) {
          $targets += $clean
        }
      } elseif ($author.reason -eq 'instagram_429') {
        Write-AgentLog "Post $postUrl retornou 429 ao testar autor via $($source.type): $($source.label). Fonte em espera, outras fontes continuam."
        Set-SourceTypeCooldown ([string]$source.type)
        Advance-SourceType $settings
        return @($targets | Select-Object -Unique)
      } else {
        $reason = if ($author.reason) { [string]$author.reason } else { 'author_not_found' }
        Add-BadPost $postUrl $reason
        Write-AgentLog "Post descartado via $($source.type): $($source.label) post=$postUrl reason=$reason"
      }
    } catch {
      Write-AgentLog "Erro inesperado testando post $postUrl via $($source.type): $($_.Exception.Message)"
      continue
    }
    if ($targets.Count -ge 8) { break }
  }

  if ($targets.Count -gt 0) {
    Navigate-Cdp $ws ([string]$source.url) 3
    Advance-SourceType $settings
    return @($targets | Select-Object -Unique)
  }
  Advance-SourceType $settings
  return @()
}

# ------------------------------------------------------------------
# Shared browser-side helpers, ported 1:1 from extensao-chrome/content.js
# so the Windows Agent detects "like the post (not a comment)", "the
# real comment submit button (not the one that opens the composer)"
# and "already following / already liked / private / own account"
# exactly the same way the Chrome Extension does. This block is a
# LITERAL (single-quoted) here-string on purpose: it must never be
# touched by PowerShell's $variable expansion.
# ------------------------------------------------------------------
$JsCommon = @'
function clean(value) {
  return String(value || '')
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .trim()
    .toLowerCase();
}

function isVisible(el) {
  if (!el) return false;
  const rect = el.getBoundingClientRect();
  const style = getComputedStyle(el);
  return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
}

// Usado pelo clique via CDP real (Input.dispatchMouseEvent, disparado pelo
// PowerShell): rola o elemento pra vista e devolve varios pontos candidatos.
// A v5.7.1 valida cada ponto com elementFromPoint antes de clicar; assim o
// Agent nao dispara um clique quando uma camada invisivel/card sobreposto esta
// interceptando o botao visivel.
function elementClickCandidates(el) {
  const rect0 = el.getBoundingClientRect();
  const inView = rect0.top >= 0 && rect0.bottom <= window.innerHeight && rect0.left >= 0 && rect0.right <= window.innerWidth;
  if (!inView && el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
  const rect = el.getBoundingClientRect();
  const jitterX = (Math.random() * Math.min(8, rect.width * 0.24)) - Math.min(4, rect.width * 0.12);
  const jitterY = (Math.random() * Math.min(5, rect.height * 0.24)) - Math.min(2.5, rect.height * 0.12);
  const points = [
    [0.50, 0.50, jitterX, jitterY],
    [0.50, 0.50, 0, 0],
    [0.38, 0.50, 0, 0],
    [0.62, 0.50, 0, 0],
    [0.50, 0.36, 0, 0],
    [0.50, 0.64, 0, 0],
    [0.32, 0.50, 0, 0],
    [0.68, 0.50, 0, 0]
  ];
  return points.map(function (p, index) {
    return {
      index: index,
      x: Math.max(0, Math.min(window.innerWidth - 1, rect.left + rect.width * p[0] + p[2])),
      y: Math.max(0, Math.min(window.innerHeight - 1, rect.top + rect.height * p[1] + p[3]))
    };
  });
}

// Marca o elemento que vamos clicar, sem calcular a coordenada ainda. A
// coordenada so e calculada depois da pausa "humana" (ver PowerShell), bem
// no instante antes do clique de verdade - assim, se o elemento acabou de
// aparecer/ficar habilitado (ex: botao "Postar" que so surge apos validar o
// texto digitado) e ainda estiver se ajustando na tela, o clique usa a
// posicao atual, nao uma posicao de centenas de ms atras que pode ja nao
// valer mais.
function markClickTarget(el) {
  document.querySelectorAll('[data-fi-click-target]').forEach(function (e) { e.removeAttribute('data-fi-click-target'); });
  el.setAttribute('data-fi-click-target', '1');
}

function hitTestMarkedClickPoint(el, point) {
  let hitMatch = false;
  let hitTag = null;
  let hitText = null;
  try {
    const topEl = document.elementFromPoint(Math.round(point.x), Math.round(point.y));
    hitTag = topEl ? topEl.tagName : null;
    hitText = topEl ? clean(topEl.innerText || topEl.textContent || '').slice(0, 40) : null;
    hitMatch = !!(topEl && (
      topEl === el ||
      el.contains(topEl) ||
      topEl.contains(el) ||
      (topEl.closest && topEl.closest('[data-fi-click-target]') === el)
    ));
  } catch (e) {
    hitText = 'hit_test_error: ' + String((e && e.message) || e).slice(0, 80);
  }
  return {
    x: point.x,
    y: point.y,
    candidateIndex: point.index,
    hitMatch: hitMatch,
    hitTag: hitTag,
    hitText: hitText
  };
}

function sanitizeHitTestResults(hits) {
  return (hits || []).slice(0, 4).map(function (hit) {
    return {
      x: hit.x,
      y: hit.y,
      candidateIndex: hit.candidateIndex,
      hitMatch: hit.hitMatch,
      hitTag: hit.hitTag,
      hitText: hit.hitText
    };
  });
}

// v5.7.1: o hit-test deixou de ser apenas diagnostico. Se nenhum ponto do
// botao realmente acerta o alvo marcado, o Agent nao clica e retorna
// click_intercepted para evitar falsos "429" ou cliques sem efeito.
function refreshMarkedClickPoint() {
  const el = document.querySelector('[data-fi-click-target]');
  if (!el) return { ok: false, reason: 'click_target_lost' };
  const candidates = elementClickCandidates(el);
  const hits = candidates.map(function (point) { return hitTestMarkedClickPoint(el, point); });
  const usable = hits.find(function (hit) { return hit.hitMatch; });
  if (usable) {
    return {
      ok: true,
      x: usable.x,
      y: usable.y,
      candidateIndex: usable.candidateIndex,
      hitMatch: true,
      hitTag: usable.hitTag,
      hitText: usable.hitText,
      allHits: sanitizeHitTestResults(hits)
    };
  }
  const first = hits[0] || { x: 0, y: 0, hitTag: null, hitText: null };
  return {
    ok: false,
    reason: 'click_intercepted',
    x: first.x,
    y: first.y,
    hitMatch: false,
    hitTag: first.hitTag,
    hitText: first.hitText,
    allHits: sanitizeHitTestResults(hits)
  };
}

function sleep(ms) {
  return new Promise(function (resolve) { setTimeout(resolve, ms); });
}

function humanClick(el) {
  const rect = el.getBoundingClientRect();
  const x = rect.left + rect.width / 2 + (Math.random() * 4 - 2);
  const y = rect.top + rect.height / 2 + (Math.random() * 4 - 2);
  ['mouseover', 'mouseenter', 'mousemove', 'mousedown', 'mouseup'].forEach(function (type) {
    el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: x, clientY: y }));
  });
  if (typeof el.click === 'function') el.click();
  else el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: x, clientY: y }));
}

function humanDoubleClick(el) {
  const rect = el.getBoundingClientRect();
  const x = rect.left + rect.width / 2 + (Math.random() * 8 - 4);
  const y = rect.top + rect.height / 2 + (Math.random() * 8 - 4);
  if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' });
  ['mouseover', 'mouseenter', 'mousemove'].forEach(function (type) {
    el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: x, clientY: y }));
  });
  for (let detail = 1; detail <= 2; detail += 1) {
    el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: x, clientY: y, detail: detail }));
    el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: x, clientY: y, detail: detail }));
    el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: x, clientY: y, detail: detail }));
  }
  el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: x, clientY: y, detail: 2 }));
}

// ---------- post / comment scope detection ----------

function getPrimaryPostArticle() {
  const articles = Array.prototype.slice.call(document.querySelectorAll('main article, article'));
  return articles.find(isVisible) || null;
}

function getActionLabels(scope) {
  const nodes = (scope && scope.querySelectorAll) ? scope.querySelectorAll('[aria-label]') : [];
  return Array.prototype.slice.call(nodes).map(function (el) { return clean(el.getAttribute('aria-label')); });
}

function getSectionActionSummary(section) {
  const labels = getActionLabels(section);
  const hasCommentAction = labels.some(function (l) { return l === 'comentar' || l === 'comment'; });
  const hasShareAction = labels.some(function (l) { return l.indexOf('compartilhar') !== -1 || l.indexOf('share') !== -1 || l.indexOf('send') !== -1 || l.indexOf('enviar') !== -1; });
  const hasSaveAction = labels.some(function (l) { return l.indexOf('salvar') !== -1 || l.indexOf('save') !== -1; });
  return { labels: labels, hasCommentAction: hasCommentAction, hasShareAction: hasShareAction, hasSaveAction: hasSaveAction };
}

function matchesPostActionLabel(label, kinds) {
  const c = clean(label);
  if (!c) return false;
  const unlike = c.indexOf('descurtir') !== -1 || c.indexOf('remover curtida') !== -1 || c.indexOf('remove like') !== -1 || c.indexOf('unlike') !== -1;
  const like = !unlike && (
    c === 'curtir' || c === 'like' ||
    c.indexOf('curtir ') === 0 || c.indexOf('like ') === 0 ||
    c.indexOf('curtir publicação') !== -1 || c.indexOf('curtir postagem') !== -1 ||
    c.indexOf('like this post') !== -1 || c.indexOf('like post') !== -1
  );
  return (kinds.indexOf('like') !== -1 && like) || (kinds.indexOf('unlike') !== -1 && unlike);
}

function hasPostActionGroup(labels) {
  const hasLike = labels.some(function (l) { return matchesPostActionLabel(l, ['like', 'unlike']); });
  const hasComment = labels.some(function (l) { return l === 'comentar' || l === 'comment' || l.indexOf('comentario') !== -1; });
  const hasShare = labels.some(function (l) { return l.indexOf('compartilhar') !== -1 || l.indexOf('share') !== -1 || l.indexOf('send') !== -1 || l.indexOf('enviar') !== -1; });
  return hasLike && hasComment && hasShare;
}

function isScrollableCommentPaneElement(el, precomputedOverflowY) {
  if (!el || el === document.body) return false;
  const overflowY = precomputedOverflowY === undefined
    ? (getComputedStyle(el).overflowY || getComputedStyle(el).overflow || '')
    : precomputedOverflowY;
  const scrollable = /auto|scroll/i.test(overflowY) || el.scrollHeight > el.clientHeight + 40;
  if (!scrollable) return false;
  const text = clean(el.innerText || el.textContent || '');
  return text.indexOf('responder') !== -1 || text.indexOf('reply') !== -1;
}

function isInsideScrollableCommentPane(el, scope) {
  let parent = el.parentElement;
  while (parent && parent !== scope) {
    const overflowY = getComputedStyle(parent).overflowY || getComputedStyle(parent).overflow || '';
    if (isScrollableCommentPaneElement(parent, overflowY)) {
      const text = clean(parent.innerText || parent.textContent || '');
      if (text.indexOf('responder') !== -1 || text.indexOf('reply') !== -1) return true;
    }
    parent = parent.parentElement;
  }
  return false;
}

function getPrimaryPostScope() {
  const article = getPrimaryPostArticle();
  const mains = Array.prototype.slice.call(document.querySelectorAll('main'));
  const main = mains.find(isVisible);
  if (!article) return main || document;
  const labels = getActionLabels(article);
  return hasPostActionGroup(labels) ? article : (main || article);
}

function isCompactPostActionGroup(group, likeButton) {
  if (!group || !likeButton || !group.contains(likeButton)) return false;
  const groupRect = group.getBoundingClientRect();
  const likeRect = likeButton.getBoundingClientRect();
  if (!groupRect.width || !groupRect.height || !likeRect.width || !likeRect.height) return false;
  const compactHorizontal = groupRect.height <= 180;
  const compactVertical = groupRect.width <= 180 && groupRect.height <= 520;
  if (!compactHorizontal && !compactVertical) return false;

  const controls = Array.prototype.slice.call(group.querySelectorAll('[aria-label]'))
    .map(function (item) {
      const btn = (item.closest && item.closest('button, [role="button"]')) || item;
      return { label: clean(item.getAttribute('aria-label') || ''), rect: btn.getBoundingClientRect() };
    })
    .filter(function (entry) { return entry.rect.width > 0 && entry.rect.height > 0; });

  const likeX = likeRect.left + likeRect.width / 2;
  const likeY = likeRect.top + likeRect.height / 2;
  function nearby(entry) {
    const x = entry.rect.left + entry.rect.width / 2;
    const y = entry.rect.top + entry.rect.height / 2;
    const dx = Math.abs(x - likeX);
    const dy = Math.abs(y - likeY);
    return (compactHorizontal && dy <= 90 && dx <= 520) || (compactVertical && dx <= 90 && dy <= 420);
  }

  const hasNearbyComment = controls.some(function (e) {
    return nearby(e) && (e.label === 'comentar' || e.label === 'comment' || e.label.indexOf('comentario') !== -1);
  });
  const hasNearbyShare = controls.some(function (e) {
    return nearby(e) && (e.label.indexOf('compartilhar') !== -1 || e.label.indexOf('share') !== -1 || e.label.indexOf('send') !== -1 || e.label.indexOf('enviar') !== -1);
  });
  return hasNearbyComment && hasNearbyShare;
}

function findNearestPostActionGroup(button, scope) {
  let parent = button.parentElement;
  for (let depth = 0; parent && parent !== scope && depth < 8; depth += 1) {
    if (isScrollableCommentPaneElement(parent)) return null;
    const labels = getActionLabels(parent);
    if (hasPostActionGroup(labels) && isCompactPostActionGroup(parent, button)) return parent;
    parent = parent.parentElement;
  }
  const groups = Array.prototype.slice.call((scope || document).querySelectorAll('section, div'));
  return groups.find(function (group) {
    return group.contains(button) && hasPostActionGroup(getActionLabels(group)) && isCompactPostActionGroup(group, button);
  }) || null;
}

function isCommentLikeButton(button, scope) {
  if (!scope || !scope.contains(button)) return false;
  if (findNearestPostActionGroup(button, scope)) return false;
  if (isInsideScrollableCommentPane(button, scope)) return true;
  const section = button.closest('section');
  if (section && scope.contains(section)) {
    const summary = getSectionActionSummary(section);
    if (summary.hasCommentAction && summary.hasShareAction) return false;
  }
  const listItem = button.closest('li, ul, ol, [role="list"], [role="listitem"]');
  if (listItem && scope.contains(listItem) && clean(listItem.innerText || listItem.textContent || '').indexOf('responder') !== -1) return true;
  let parent = button.parentElement;
  for (let depth = 0; parent && parent !== scope && depth < 6; depth += 1) {
    const t = clean(parent.innerText || parent.textContent || '');
    if (t.indexOf('responder') !== -1 || t.indexOf('reply') !== -1) return true;
    parent = parent.parentElement;
  }
  return false;
}

// ---------- like ----------

function findStrictLikeButton(scope) {
  const candidates = Array.prototype.slice.call((scope || document).querySelectorAll(
    'button[aria-label], [role="button"][aria-label], button [aria-label], [role="button"] [aria-label], svg[aria-label]'
  ));
  for (let i = 0; i < candidates.length; i += 1) {
    const candidate = candidates[i];
    const btn = (candidate.closest && candidate.closest('button, [role="button"]')) || candidate;
    if (!isVisible(btn) || btn.disabled || btn.getAttribute('aria-disabled') === 'true') continue;
    const label = clean(candidate.getAttribute('aria-label') || btn.getAttribute('aria-label') || '');
    if (label !== 'curtir' && label !== 'like') continue;
    if (isInsideScrollableCommentPane(btn, scope || document)) continue;
    if (!findNearestPostActionGroup(btn, scope || document)) continue;
    return btn;
  }
  return null;
}

function findPrimaryPostActionButton(scope, kinds) {
  const candidates = Array.prototype.slice.call((scope || document).querySelectorAll(
    'button[aria-label], [role="button"][aria-label], button [aria-label], [role="button"] [aria-label], svg[aria-label]'
  ));
  const buttons = [];
  for (let i = 0; i < candidates.length; i += 1) {
    const candidate = candidates[i];
    const button = (candidate.closest && candidate.closest('button, [role="button"]')) || candidate;
    if (!button || !isVisible(button) || button.disabled || button.getAttribute('aria-disabled') === 'true') continue;
    const label = clean(candidate.getAttribute('aria-label') || button.getAttribute('aria-label') || '');
    if (matchesPostActionLabel(label, kinds)) buttons.push(button);
  }
  const uniqueButtons = Array.from(new Set(buttons));
  const scored = uniqueButtons
    .map(function (button) { return { button: button, score: scorePrimaryPostActionButton(button, scope) }; })
    .filter(function (item) { return item.score >= 35; })
    .sort(function (a, b) { return b.score - a.score; });
  return (scored[0] && scored[0].button) || null;
}

function findLikelyPostLikeButton(scope) {
  const root = scope || document;
  const selectors = 'button, [role="button"], svg[aria-label]';
  const candidates = Array.prototype.slice.call(root.querySelectorAll(selectors))
    .map(function (item) { return (item.closest && item.closest('button, [role="button"]')) || item; })
    .filter(function (button, index, list) {
      if (!button || list.indexOf(button) !== index) return false;
      if (!isVisible(button) || button.disabled || button.getAttribute('aria-disabled') === 'true') return false;
      if (button.closest('header, nav')) return false;
      if (isCommentLikeButton(button, root)) return false;
      if (isInsideScrollableCommentPane(button, root)) return false;
      const rect = button.getBoundingClientRect();
      return rect.width >= 16 && rect.height >= 16;
    });

  const groups = Array.prototype.slice.call(root.querySelectorAll('section, footer, div'))
    .map(function (group) {
      const rect = group.getBoundingClientRect();
      if (!rect.width || !rect.height || rect.height > 220) return null;
      const labels = getActionLabels(group);
      const summary = getSectionActionSummary(group);
      const groupButtons = candidates.filter(function (button) { return group.contains(button); });
      if (groupButtons.length < 2) return null;
      const hasAnyPostAction = labels.some(function (label) {
        return matchesPostActionLabel(label, ['like', 'unlike']) ||
          label === 'comentar' || label.indexOf('comentario') !== -1 || label === 'comment' ||
          label.indexOf('compartilhar') !== -1 || label.indexOf('share') !== -1 || label.indexOf('send') !== -1 || label.indexOf('enviar') !== -1 ||
          label.indexOf('salvar') !== -1 || label.indexOf('save') !== -1;
      });
      if (!hasAnyPostAction) return null;
      let score = 0;
      if (summary.hasCommentAction) score += 35;
      if (summary.hasShareAction) score += 35;
      if (summary.hasSaveAction) score += 15;
      if (labels.some(function (label) { return matchesPostActionLabel(label, ['like']); })) score += 30;
      if (rect.height <= 110) score += 10;
      score += Math.min(10, groupButtons.length);
      return { group: group, buttons: groupButtons, labels: labels, score: score, rect: rect };
    })
    .filter(Boolean)
    .sort(function (a, b) { return b.score - a.score; });

  for (let i = 0; i < groups.length; i += 1) {
    const entry = groups[i];
    const labeled = entry.buttons.find(function (button) {
      const labels = getActionLabels(button);
      const own = clean(button.getAttribute('aria-label') || '');
      return matchesPostActionLabel(own, ['like']) || labels.some(function (label) { return matchesPostActionLabel(label, ['like']); });
    });
    if (labeled) return labeled;

    if (entry.score >= 55) {
      const sorted = entry.buttons
        .map(function (button) { return { button: button, rect: button.getBoundingClientRect() }; })
        .filter(function (item) { return item.rect.width > 0 && item.rect.height > 0; })
        .sort(function (a, b) { return a.rect.left - b.rect.left; });
      if (sorted[0]) return sorted[0].button;
    }
  }
  return null;
}

function diagnoseLikeFailure(scope) {
  try {
    const labels = Array.prototype.slice.call((scope || document).querySelectorAll('[aria-label]'))
      .map(function (item) {
        const btn = (item.closest && item.closest('button, [role="button"]')) || item;
        const rect = btn.getBoundingClientRect();
        return {
          label: item.getAttribute('aria-label') || '',
          visible: isVisible(btn),
          rect: [Math.round(rect.left), Math.round(rect.top), Math.round(rect.width), Math.round(rect.height)],
          score: scorePrimaryPostActionButton(btn, scope || document)
        };
      })
      .filter(function (item) { return item.visible; })
      .slice(0, 18);
    return { href: location.href, labels: labels };
  } catch (e) {
    return { href: location.href, error: String(e && e.message || e) };
  }
}

function scorePrimaryPostActionButton(button, scope) {
  if (!scope || !scope.contains(button) || isCommentLikeButton(button, scope)) return -100;
  if (isInsideScrollableCommentPane(button, scope) && !findNearestPostActionGroup(button, scope)) return -80;
  let score = 15;
  const section = findNearestPostActionGroup(button, scope);
  if (!section || !scope.contains(section)) return score;
  const summary = getSectionActionSummary(section);
  if (summary.hasCommentAction) score += 25;
  if (summary.hasShareAction) score += 25;
  if (summary.hasSaveAction) score += 10;
  if (summary.labels.length >= 3) score += 10;
  return score;
}

function findPrimaryPostMedia(scope) {
  if (!scope) return null;
  const items = Array.prototype.slice.call(scope.querySelectorAll('video, img, canvas'))
    .filter(function (item) {
      if (!isVisible(item)) return false;
      if (item.closest('header, footer, nav')) return false;
      if (isInsideScrollableCommentPane(item, scope)) return false;
      const rect = item.getBoundingClientRect();
      return rect.width >= 180 && rect.height >= 180;
    })
    .sort(function (a, b) {
      const ra = a.getBoundingClientRect();
      const rb = b.getBoundingClientRect();
      return (rb.width * rb.height) - (ra.width * ra.height);
    });
  return items[0] || null;
}

function findUnlikeButton(scope) {
  return findPrimaryPostActionButton(scope, ['unlike']);
}

function isPrimaryPostLiked(scope) {
  return !!findUnlikeButton(scope);
}

async function waitForPrimaryPostLiked(scope, timeout) {
  const end = Date.now() + (timeout || 6000);
  while (Date.now() < end) {
    if (isPrimaryPostLiked(scope)) return true;
    await sleep(250);
  }
  return false;
}

// ---------- comment box ----------

function scoreCommentBoxCandidate(item, scope) {
  if (!item || !isVisible(item) || item.disabled || item.getAttribute('aria-disabled') === 'true') return 0;
  if (scope && !scope.contains(item)) return 0;
  if (item.closest('header, nav, [role="dialog"] header')) return 0;

  const label = clean((item.getAttribute('aria-label') || '') + ' ' + (item.getAttribute('placeholder') || '') + ' ' + (item.getAttribute('data-placeholder') || ''));
  const text = clean(item.innerText || item.textContent || '');
  if (label.indexOf('pesquisar') !== -1 || label.indexOf('search') !== -1) return 0;

  const isTextArea = item.tagName === 'TEXTAREA';
  const isInput = item.tagName === 'INPUT';
  const isEditable = item.isContentEditable || item.getAttribute('contenteditable') === 'true';
  const hasCommentLabel = label.indexOf('coment') !== -1 || label.indexOf('comment') !== -1 || text.indexOf('adicione um comentario') !== -1 || text.indexOf('add a comment') !== -1;
  if (!hasCommentLabel) return 0;

  const rect = item.getBoundingClientRect();
  let score = 20;
  score += 60;
  if (isTextArea || isInput) score += 20;
  if (isEditable) score += 15;
  if (clean(item.getAttribute('role') || '') === 'textbox') score += 10;
  if (!isInsideScrollableCommentPane(item, scope || document)) score += 10;
  score += Math.min(20, Math.max(0, rect.top / Math.max(1, window.innerHeight)) * 20);
  return score;
}

function findVisibleCommentBox(scope) {
  const items = Array.prototype.slice.call(scope.querySelectorAll(
    'textarea[aria-label*="coment"], textarea[aria-label*="comment" i], textarea[placeholder*="coment" i], textarea[placeholder*="comment" i], textarea, [contenteditable="true"][role="textbox"], [contenteditable="true"]'
  ));
  const ranked = items
    .map(function (item) { return { item: item, score: scoreCommentBoxCandidate(item, scope === document ? null : scope) }; })
    .filter(function (entry) { return entry.score > 0; })
    .sort(function (a, b) { return b.score - a.score; });
  return (ranked[0] && ranked[0].item) || null;
}

async function findCommentBox(scope, timeout) {
  const end = Date.now() + (timeout || 7000);
  while (Date.now() < end) {
    const box = findVisibleCommentBox(scope || document);
    if (box) return box;
    await sleep(300);
  }
  return null;
}

function normalizeUsernameText(value) {
  return clean(value).replace(/^@+/, '').replace(/[^a-z0-9._]/g, '');
}

function findOwnCommentInfo(scope, username) {
  const own = normalizeUsernameText(username);
  const root = scope || document;
  if (!own || !root) return { found: false, username: own };
  const links = Array.prototype.slice.call(root.querySelectorAll('a[href]'));
  for (let i = 0; i < links.length; i += 1) {
    const link = links[i];
    if (!isVisible(link) || link.closest('header')) continue;
    let hrefUser = '';
    try {
      const url = new URL(link.href, location.href);
      const parts = url.pathname.split('/').filter(Boolean);
      if (parts.length === 1) hrefUser = normalizeUsernameText(parts[0]);
    } catch (e) {}
    const textUser = normalizeUsernameText(link.innerText || link.textContent || '');
    if (hrefUser !== own && textUser !== own) continue;
    let container = link.parentElement;
    for (let depth = 0; container && container !== root && depth < 8; depth += 1) {
      if (!isVisible(container)) { container = container.parentElement; continue; }
      if (container.querySelector('textarea, input, [contenteditable="true"], [data-fi-comment-box]')) {
        container = container.parentElement;
        continue;
      }
      const text = clean(container.innerText || container.textContent || '');
      if (text.indexOf(own) !== -1 && text.length > own.length + 6) {
        return { found: true, username: own, sample: text.slice(0, 180) };
      }
      container = container.parentElement;
    }
  }
  return { found: false, username: own };
}

function getEditableText(el) {
  if (!el) return '';
  if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.value || '';
  return el.innerText || el.textContent || '';
}

function editableContainsText(el, value) {
  const actual = clean(getEditableText(el));
  const expected = clean(value);
  return !!expected && actual.indexOf(expected) !== -1;
}

function setEditableValue(el, value) {
  if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
    const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
    const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
    const setter = descriptor && descriptor.set;
    el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: value }));
    if (setter) setter.call(el, value); else el.value = value;
    if (el.setSelectionRange) el.setSelectionRange(value.length, value.length);
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
    el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: value.slice(-1) || ' ' }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
    return;
  }
  el.focus();
  const selection = window.getSelection ? window.getSelection() : null;
  const range = document.createRange ? document.createRange() : null;
  if (selection && range) {
    range.selectNodeContents(el);
    selection.removeAllRanges();
    selection.addRange(range);
  }
  el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: value }));
  let inserted = false;
  try { inserted = document.execCommand && document.execCommand('insertText', false, value); } catch (e) { inserted = false; }
  if (!inserted || !editableContainsText(el, value)) {
    el.textContent = '';
    el.appendChild(document.createTextNode(value));
  }
  el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
  el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: value.slice(-1) || ' ' }));
  el.dispatchEvent(new Event('change', { bubbles: true }));
}

async function writeCommentText(el, value) {
  const text = String(value || '').trim();
  if (!text) return false;
  for (let attempt = 0; attempt < 3; attempt += 1) {
    if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'nearest' });
    await sleep(attempt === 0 ? 100 : 250);
    el.focus();
    humanClick(el);
    await sleep(150);
    setEditableValue(el, text);
    await sleep(250);
    if (editableContainsText(el, text)) return true;
  }
  return false;
}

function isSubmitCommentButton(button) {
  if (!isVisible(button) || button.disabled || button.getAttribute('aria-disabled') === 'true') return false;
  const text = clean(button.innerText || button.textContent || button.getAttribute('aria-label') || '');
  if (!text) return false;
  return text === 'publicar' || text === 'postar' || text === 'post' || text === 'publish';
}

function diagnoseCommentFailure(box, scope) {
  try {
    const allTextBoxes = Array.prototype.slice.call((scope || document).querySelectorAll('textarea, [contenteditable="true"]'));
    const boxesInfo = allTextBoxes.slice(0, 8).map(function (item) {
      const rect = item.getBoundingClientRect();
      return {
        tag: item.tagName,
        aria: item.getAttribute('aria-label') || '',
        visible: isVisible(item),
        rect: [Math.round(rect.left), Math.round(rect.top), Math.round(rect.width), Math.round(rect.height)],
        score: scoreCommentBoxCandidate(item, scope === document ? null : scope)
      };
    });
    let chosen = null;
    if (box) {
      const rect = box.getBoundingClientRect();
      chosen = {
        tag: box.tagName,
        aria: box.getAttribute('aria-label') || '',
        rect: [Math.round(rect.left), Math.round(rect.top), Math.round(rect.width), Math.round(rect.height)],
        textNow: clean(getEditableText(box)).slice(0, 40)
      };
    }
    const submitCandidates = Array.prototype.slice.call(document.querySelectorAll('button, [role="button"]'))
      .filter(isSubmitCommentButton)
      .map(function (b) {
        const rect = b.getBoundingClientRect();
        return {
          rect: [Math.round(rect.left), Math.round(rect.top), Math.round(rect.width), Math.round(rect.height)],
          disabled: !!(b.disabled || b.getAttribute('aria-disabled') === 'true')
        };
      });
    // A partir da v5.4.13: mesma checagem de aviso de bloqueio do Instagram
    // ja usada em follow/unfollow (v5.4.10+), agora tambem aqui - ainda nao
    // tinha sido estendida pra comentario. Tambem registra se ha um dialog
    // aberto (ex: aviso/confirmacao inesperada cobrindo o formulario).
    const dialog = document.querySelector('[role="dialog"]');
    return {
      looksBlocked: looksBlockedOnPage(),
      hasOpenDialog: !!dialog,
      dialogText: dialog ? clean(dialog.innerText || '').slice(0, 200) : null,
      totalTextBoxesOnPage: allTextBoxes.length,
      boxesInfo: boxesInfo,
      chosenBox: chosen,
      submitCandidateCount: submitCandidates.length,
      submitCandidates: submitCandidates
    };
  } catch (e) {
    return { diagError: String((e && e.message) || e) };
  }
}

async function findSubmitCommentButton(box, scope, commentText) {
  if (!box) return { button: null, box: null };
  let liveBox = box;
  const initialRect = box.getBoundingClientRect();
  if (initialRect.width === 0 && initialRect.height === 0) {
    const fresh = findVisibleCommentBox(scope || document);
    if (fresh) {
      liveBox = fresh;
      // The Instagram remount that killed the old textarea always creates a
      // BLANK replacement - it never carries the typed text over. Without
      // text in the box, Instagram has no Publicar/Post button to show at
      // all, so re-typing here (instead of just swapping the reference) is
      // what actually lets the button appear.
      if (commentText && !editableContainsText(liveBox, commentText)) {
        await writeCommentText(liveBox, commentText);
      }
    }
  }
  const form = liveBox.closest('form');
  if (form) {
    const button = Array.prototype.slice.call(form.querySelectorAll('button, [role="button"]')).find(isSubmitCommentButton);
    if (button) return { button: button, box: liveBox };
  }
  const boxRect = liveBox.getBoundingClientRect();
  const boxX = boxRect.left + boxRect.width / 2;
  const boxY = boxRect.top + boxRect.height / 2;
  const candidates = Array.prototype.slice.call(document.querySelectorAll('button, [role="button"]')).filter(isSubmitCommentButton);
  let best = null;
  let bestDist = Infinity;
  for (let i = 0; i < candidates.length; i += 1) {
    const rect = candidates[i].getBoundingClientRect();
    const dist = Math.hypot((rect.left + rect.width / 2) - boxX, (rect.top + rect.height / 2) - boxY);
    if (dist < bestDist) { bestDist = dist; best = candidates[i]; }
  }
  return { button: (best && bestDist <= 400) ? best : null, box: liveBox };
}

async function waitForSubmitCommentButton(box, timeout, scope, commentText) {
  const end = Date.now() + (timeout || 7000);
  let liveBox = box;
  while (Date.now() < end) {
    const found = await findSubmitCommentButton(liveBox, scope, commentText);
    if (found.box) liveBox = found.box;
    if (found.button) return { submit: found.button, box: liveBox };
    await sleep(300);
  }
  return { submit: null, box: liveBox };
}

function countVisibleCommentText(text, box, scope) {
  if (!scope) return 0;
  const needle = clean(text).slice(0, 80);
  if (!needle) return 0;
  const candidates = Array.prototype.slice.call(scope.querySelectorAll(
    'li, [role="listitem"], article ul > div, article div, article p, article span, main ul > div, main div, main p, main span'
  ));
  const matches = candidates.filter(function (item) {
    if (!isVisible(item) || item.contains(box) || item.matches('textarea, input, [contenteditable="true"]')) return false;
    const ownText = clean(item.innerText || item.textContent || '');
    if (ownText.indexOf(needle) === -1) return false;
    return !Array.prototype.slice.call(item.children).some(function (child) {
      if (!isVisible(child) || child.contains(box)) return false;
      return clean(child.innerText || child.textContent || '').indexOf(needle) !== -1;
    });
  });
  return matches.length;
}

async function waitForCommentVisible(text, box, scope, timeout, minimumPreviousCount) {
  const end = Date.now() + (timeout || 8000);
  while (Date.now() < end) {
    if (countVisibleCommentText(text, box, scope) > (minimumPreviousCount || 0)) return true;
    await sleep(300);
  }
  return false;
}

// v5.5.0: variante de waitForCommentVisible que, quando o prazo acaba sem
// confirmar, devolve um retrato do estado (em vez de so false) - isso e o
// que permite ao PowerShell decidir se e SEGURO tentar um segundo clique:
// so reclicamos se a caixa ainda estiver anexada, com o texto original
// intacto, e um botao de envio ainda habilitado - ou seja, se tudo indica
// que o primeiro clique simplesmente nao teve efeito nenhum. Se qualquer
// uma dessas condicoes mudou, nao reclicamos (evita comentario duplicado).
async function waitForCommentVisibleWithState(text, box, scope, timeout, minimumPreviousCount) {
  const end = Date.now() + (timeout || 8000);
  while (Date.now() < end) {
    if (countVisibleCommentText(text, box, scope) > (minimumPreviousCount || 0)) return { confirmed: true };
    await sleep(300);
  }
  const attached = !!(box && document.contains(box));
  const textStillPresent = attached ? editableContainsText(box, text) : false;
  const submitCandidates = Array.prototype.slice.call(document.querySelectorAll('button, [role="button"]')).filter(isSubmitCommentButton);
  return {
    confirmed: false,
    boxAttached: attached,
    textStillPresent: textStillPresent,
    hasEnabledSubmit: submitCandidates.length > 0
  };
}

// ---------- follow ----------

function findFollowButton() {
  const buttons = document.querySelectorAll('button, [role="button"]');
  for (let i = 0; i < buttons.length; i += 1) {
    const button = buttons[i];
    if (!isVisible(button) || button.disabled || button.getAttribute('aria-disabled') === 'true') continue;
    const text = clean(button.innerText);
    if (text === 'seguir' || text === 'follow' || text === 'seguir de volta' || text === 'follow back') return button;
  }
  return null;
}

function findFollowingButton() {
  const buttons = document.querySelectorAll('button, [role="button"]');
  for (let i = 0; i < buttons.length; i += 1) {
    const button = buttons[i];
    if (!isVisible(button) || button.disabled || button.getAttribute('aria-disabled') === 'true') continue;
    const text = clean(button.innerText);
    if (text === 'seguindo' || text === 'following') return button;
  }
  return null;
}

function findRequestedButton() {
  const buttons = document.querySelectorAll('button, [role="button"]');
  for (let i = 0; i < buttons.length; i += 1) {
    const button = buttons[i];
    const text = clean(button.innerText);
    if (text === 'solicitado' || text === 'requested') return button;
  }
  return null;
}

async function waitForUnfollowConfirmDialog(timeout) {
  const end = Date.now() + (timeout || 5000);
  while (Date.now() < end) {
    const dialogButtons = Array.prototype.slice.call(document.querySelectorAll('[role="dialog"] button, [role="dialog"] [role="button"], button, [role="button"]'));
    const confirmBtn = dialogButtons.find(function (b) {
      if (!isVisible(b) || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
      const t = clean(b.innerText || b.textContent || '');
      return t === 'deixar de seguir' || t === 'unfollow' || t.indexOf('deixar de seguir') !== -1;
    });
    if (confirmBtn) return confirmBtn;
    await sleep(200);
  }
  return null;
}

async function waitForUnfollowConfirmation(timeout) {
  const end = Date.now() + (timeout || 6000);
  while (Date.now() < end) {
    if (!isAlreadyFollowing()) return true;
    await sleep(250);
  }
  return false;
}

function isAlreadyFollowing() {
  return !!findFollowingButton() || !!findRequestedButton();
}

async function waitForFollowConfirmation(timeout) {
  const start = Date.now();
  while (Date.now() - start < (timeout || 5000)) {
    if (isAlreadyFollowing()) return true;
    await sleep(300);
  }
  return false;
}

// Checagem compartilhada do aviso proprio do Instagram de "Acao bloqueada" /
// "Tente novamente mais tarde" - limite de velocidade do lado do cliente,
// diferente do 429 de rede que ja tratamos em isRateLimitedPage(). Usada em
// follow/unfollow desde a v5.4.10 e, a partir da v5.4.13, tambem em comentario.
function looksBlockedOnPage() {
  const bodyText = clean(document.body.innerText || '');
  const blockPatterns = [
    'acao bloqueada', 'nos restringimos', 'tente novamente mais tarde',
    'try again later', 'action blocked', 'we restrict certain activity',
    'limite de acoes', 'restringimos determinadas atividades',
    'adicionamos uma restricao', "we've limited", 'we have limited'
  ];
  return blockPatterns.some(function (p) { return bodyText.indexOf(clean(p)) !== -1; });
}

// Diagnostico pra quando o clique nao e confirmado em follow/unfollow.
function diagnoseFollowActionFailure() {
  try {
    const looksBlocked = looksBlockedOnPage();
    const followBtn = findFollowButton();
    const followingBtn = findFollowingButton();
    const requestedBtn = findRequestedButton();
    const dialog = document.querySelector('[role="dialog"]');
    return {
      looksBlocked: looksBlocked,
      currentButtonText: followBtn ? clean(followBtn.innerText) : (followingBtn ? clean(followingBtn.innerText) : (requestedBtn ? clean(requestedBtn.innerText) : null)),
      hasFollowButton: !!followBtn,
      hasFollowingButton: !!followingBtn,
      hasRequestedButton: !!requestedBtn,
      hasOpenDialog: !!dialog,
      dialogText: dialog ? clean(dialog.innerText || '').slice(0, 200) : null
    };
  } catch (e) {
    return { diagError: String((e && e.message) || e) };
  }
}

function isFollowingBack() {
  const text = clean(document.body.innerText);
  return text.indexOf('segue você') !== -1 || text.indexOf('follows you') !== -1;
}

function isPrivateAccount() {
  const text = clean(document.body.innerText);
  return text.indexOf('essa conta e privada') !== -1 ||
    text.indexOf('esta conta e privada') !== -1 ||
    text.indexOf('este perfil e privado') !== -1 ||
    text.indexOf('esse perfil e privado') !== -1 ||
    text.indexOf('this account is private') !== -1 ||
    text.indexOf('this profile is private') !== -1;
}

function isOwnProfilePage() {
  const text = clean(document.body.innerText);
  return text.indexOf('editar perfil') !== -1 || text.indexOf('edit profile') !== -1;
}

function isRateLimitedPage() {
  const text = clean((document.title || '') + ' ' + (document.body ? document.body.innerText || '' : ''));
  return text.indexOf('http error 429') !== -1 ||
    text.indexOf('error 429') !== -1 ||
    text.indexOf('too many requests') !== -1 ||
    text.indexOf('muitas solicitacoes') !== -1;
}

function parseCompactNumber(raw) {
  const text = clean(raw).replace(/\s+/g, '');
  const match = text.match(/([\d.,]+)(mil|mi|k|m)?/);
  if (!match) return null;
  let value = parseFloat(match[1].replace(/\./g, '').replace(',', '.'));
  if (!isFinite(value)) return null;
  const suffix = match[2];
  if (suffix === 'mil' || suffix === 'k') value *= 1000;
  if (suffix === 'mi' || suffix === 'm') value *= 1000000;
  return Math.round(value);
}

function parseFollowerText(raw) {
  const text = clean(raw || '').replace(/\s+/g, ' ').trim();
  if (!text || (text.indexOf('seguidor') === -1 && text.indexOf('followers') === -1)) return null;
  let match = text.match(/([\d.,]+)\s*(mil|mi|k|m)?\s*(seguidores|seguidor|followers|follower)/);
  if (!match) {
    match = text.match(/(seguidores|seguidor|followers|follower)\s*([\d.,]+)\s*(mil|mi|k|m)?/);
    if (!match) return null;
    return parseCompactNumber(match[2] + (match[3] || ''));
  }
  return parseCompactNumber(match[1] + (match[2] || ''));
}

function makeFollowerInfo(number, raw, source) {
  return { count: number, raw: String(raw || '').slice(0, 160), source: source };
}

function readFollowerInfo(raw, source) {
  const number = parseFollowerText(raw);
  return number !== null ? makeFollowerInfo(number, raw, source) : null;
}

function getFollowerInfo() {
  try {
    const header = document.querySelector('main header') || document.querySelector('header');
    if (header) {
      const links = Array.prototype.slice.call(header.querySelectorAll('a[href*="/followers/"]'));
      for (let i = 0; i < links.length; i += 1) {
        const link = links[i];
        if (!isVisible(link)) continue;
        const span = link.querySelector('span[title]');
        const raw = ((span && span.getAttribute('title')) || '') + ' ' + (link.innerText || link.textContent || '');
        const info = readFollowerInfo(raw, 'header_followers_link');
        if (info) return info;
      }

      const nodes = Array.prototype.slice.call(header.querySelectorAll('li, span[title], span, div'));
      for (let i = 0; i < nodes.length; i += 1) {
        if (!isVisible(nodes[i])) continue;
        const raw = ((nodes[i].getAttribute && nodes[i].getAttribute('title')) || '') + ' ' + (nodes[i].innerText || nodes[i].textContent || '');
        const info = readFollowerInfo(raw, 'header_node');
        if (info) return info;
      }

      const direct = readFollowerInfo(header.innerText || header.textContent || '', 'header_text');
      if (direct) return direct;
    }
  } catch (e) {}
  try {
    const links = document.querySelectorAll('main a[href*="/followers/"], a[href*="/followers/"]');
    for (let i = 0; i < links.length; i += 1) {
      const link = links[i];
      if (!isVisible(link)) continue;
      const span = link.querySelector('span[title]');
      const raw = ((span && span.getAttribute('title')) || '') + ' ' + (link.innerText || link.textContent || '');
      const info = readFollowerInfo(raw, 'visible_followers_link');
      if (info) return info;
    }
  } catch (e) {}
  return null;
}

function getFollowerCount() {
  const info = getFollowerInfo();
  return info ? info.count : null;
}

function isPageUnavailable() {
  const body = (document.body && document.body.innerText) || '';
  return /página não está disponivel|this page isn.t available|sorry, this page isn.t available/i.test(clean(body));
}
'@

function Get-CandidatePosts($ws, [string]$target, [int]$limit = 12) {
  if (Is-ReservedTarget $target) { return @{ ok=$false; reason='invalid_target_reserved' } }
  Navigate-Cdp $ws "https://www.instagram.com/$target/" 7
  $js = @"
(() => {
  const body = document.body.innerText || '';
  const first = location.pathname.split('/').filter(Boolean)[0] || '';
  const reserved = new Set(['blog','legal','privacy','terms','careers','help','api','about','developer','meta','threads','locations','popular']);
  if (reserved.has(first.toLowerCase())) return JSON.stringify({ ok:false, href:'', reason:'invalid_target_reserved' });
  if (/HTTP ERROR 429|Esta p.gina n.o est. funcionando|This page isn.t working/i.test(body)) {
    return JSON.stringify({ ok:false, href:'', reason:'instagram_429' });
  }
  if (/p.gina n.o est. dispon.vel|This page isn't available|Sorry, this page isn't available/i.test(body)) {
    return JSON.stringify({ ok:false, href:'', reason:'profile_unavailable' });
  }
  if (/este perfil . privado|esse perfil . privado|esta conta . privada|essa conta . privada|this profile is private|this account is private/i.test(body)) {
    return JSON.stringify({ ok:false, href:'', reason:'private_account' });
  }
  const links = [];
  for (const a of document.querySelectorAll('a[href*="/p/"],a[href*="/reel/"]')) {
    try {
      const href = new URL(a.href, location.href).href.split('?')[0];
      if (!links.includes(href)) links.push(href);
      if (links.length >= __LIMIT__) break;
    } catch {}
  }
  return JSON.stringify({ ok: links.length > 0, href: links[0] || '', hrefs: links, reason: links.length ? '' : 'post_not_found' });
})()
"@
  $js = $js.Replace('__LIMIT__', [string]$limit)
  $res = Eval-Cdp $ws $js
  if ($res.ok -and $res.hrefs) {
    $usable = @($res.hrefs |
      ForEach-Object { Normalize-InstagramPostUrl ([string]$_) } |
      Where-Object { $_ -and -not $script:badPostSet.Contains($_) } |
      Select-Object -Unique -First $limit)
    return @{
      ok = ($usable.Count -gt 0)
      href = if ($usable.Count -gt 0) { $usable[0] } else { '' }
      hrefs = $usable
      reason = if ($usable.Count -gt 0) { '' } else { 'post_cache_exhausted' }
    }
  }
  return $res
}

function Get-FirstPost($ws, [string]$target) {
  $posts = Get-CandidatePosts $ws $target 1
  if ($posts.ok -and @($posts.hrefs).Count) { return @{ ok=$true; href=@($posts.hrefs)[0]; hrefs=@($posts.hrefs); reason='' } }
  return $posts
}

# v5.6.0: mesmo padrao usado no comentario (v5.5.0), generalizado pra
# qualquer acao de clique unico com confirmacao por polling: espera curta
# pelo primeiro clique; se nao confirmar E o proprio estado mostrar que
# nada mudou (o alvo do clique - botao "Seguir", ou botao "Seguindo" no
# caso do unfollow - continua exatamente como estava, ou seja, o clique
# claramente nao teve efeito nenhum), reclica numa posicao recalculada.
# So entao espera o resto do prazo. $confirmPhaseTemplate precisa devolver
# { confirmed, unchanged } - "unchanged" e quem decide se e seguro reclicar.
function Invoke-ClickConfirmWithRetry($ws, $confirmPhaseTemplate, $relocateJsTemplate, $diagJsTemplate, [string]$failReason, [int]$phase1Timeout, [int]$phase2Timeout) {
  $phase1Js = $confirmPhaseTemplate.Replace('__TIMEOUT__', [string]$phase1Timeout)
  $state1 = Eval-Cdp $ws ($JsCommon + "`n" + $phase1Js)
  if ($state1.confirmed) { return @{ ok = $true } }

  $safeToRetry = [bool]$state1.unchanged
  $retried = $false
  if ($safeToRetry) {
    $relocated = Eval-Cdp $ws ($JsCommon + "`n" + $relocateJsTemplate)
    if ($relocated.ok) {
      Start-Sleep -Milliseconds (Get-Random -Minimum 200 -Maximum 500)
      $point2 = Get-FreshClickPoint $ws
      if ($point2.ok) {
        Invoke-CdpHumanClick $ws ([double]$point2.x) ([double]$point2.y)
        $retried = $true
      }
    }
  }

  $phase2Js = $confirmPhaseTemplate.Replace('__TIMEOUT__', [string]$phase2Timeout)
  $state2 = Eval-Cdp $ws ($JsCommon + "`n" + $phase2Js)
  if ($state2.confirmed) { return @{ ok = $true; retried = $retried } }

  $diag = Eval-Cdp $ws ($JsCommon + "`n" + $diagJsTemplate)
  $detail = $diag.detail
  if ($null -eq $detail) { $detail = [PSCustomObject]@{} }
  $detail | Add-Member -NotePropertyName 'retried' -NotePropertyValue $retried -Force
  $detail | Add-Member -NotePropertyName 'safeToRetry' -NotePropertyValue $safeToRetry -Force
  $detail | Add-Member -NotePropertyName 'clickHits' -NotePropertyValue $script:clickHitLog -Force
  return @{ ok = $false; reason = $failReason; detail = $detail; retried = $retried }
}

# v5.6.1: liga a captura de eventos de rede so durante o clique de follow
# (nao fica ligado o tempo todo, pra nao pesar/poluir o resto do Agent).
# Network.enable/.disable podem falhar sem quebrar o fluxo - o pior caso e
# so nao ter evidencia de rede pra essa tentativa especifica.
function Start-FollowNetworkCapture($ws) {
  try { Invoke-Cdp $ws 'Network.enable' @{} | Out-Null } catch {}
  $script:networkEventLog = @{}
  $script:captureNetworkEvents = $true
}

# Devolve so as requisicoes XHR/Fetch vistas durante a janela de captura -
# e o tipo de chamada que a acao de "seguir" dispara pro Instagram. Nao
# filtra por URL especifica (ex. "friendships") porque ainda nao sabemos o
# formato exato do endpoint nessa conta - filtrar cedo demais podia esconder
# a evidencia real. Limita a 15 pra nao inchar o log.
function Stop-FollowNetworkCapture($ws) {
  $script:captureNetworkEvents = $false
  $evidence = @()
  if ($script:networkEventLog) {
    foreach ($key in $script:networkEventLog.Keys) {
      $entry = $script:networkEventLog[$key]
      $type = [string]$entry['type']
      if ($type -eq 'XHR' -or $type -eq 'Fetch') {
        $evidence += @{
          url = [string]$entry['url']
          requestMethod = [string]$entry['requestMethod']
          status = $entry['status']
          failed = [bool]$entry['failed']
          errorText = [string]$entry['errorText']
        }
      }
    }
  }
  try { Invoke-Cdp $ws 'Network.disable' @{} | Out-Null } catch {}
  if ($evidence.Count -gt 15) { $evidence = $evidence[0..14] }
  return ,$evidence
}

# v5.6.1: mitigacao temporaria enquanto a captura de rede acima nos diz se
# o click_not_confirmed do follow (~45% de falha no dia, mesmo com retry
# ativo desde a v5.6.0) e o Instagram descartando o follow no servidor por
# ritmo. Forca um espacamento minimo extra SO entre tentativas de follow -
# equivalente ao intervalo da velocidade "lenta" - independente da
# velocidade configurada no painel. Like/comment/unfollow continuam no
# ritmo normal configurado, sem mudanca nenhuma.
function Wait-FollowExtraSpacing() {
  if ($script:lastFollowAttemptAt) {
    $minGapSeconds = Get-Random -Minimum 50 -Maximum 100
    $elapsed = ((Get-Date) - $script:lastFollowAttemptAt).TotalSeconds
    $remaining = $minGapSeconds - $elapsed
    if ($remaining -gt 0) {
      Write-AgentLog "Espacamento extra de follow (mitigacao v5.6.1): aguardando mais $([Math]::Round($remaining))s."
      Start-Sleep -Seconds ([Math]::Ceiling($remaining))
    }
  }
  $script:lastFollowAttemptAt = Get-Date
}

function Do-Follow($ws, [string]$target, $settings) {
  $script:clickHitLog = @()
  if (Is-ReservedTarget $target) { return @{ ok=$false; reason='invalid_target_reserved' } }
  Navigate-Cdp $ws "https://www.instagram.com/$target/" 7
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
  $minFollowers = 0
  $maxFollowers = 0
  if ($settings) {
    if ($settings.min_followers) { $minFollowers = [int]$settings.min_followers }
    if ($settings.max_followers) { $maxFollowers = [int]$settings.max_followers }
  }
  $paramsJson = (@{ min_followers = $minFollowers; max_followers = $maxFollowers } | ConvertTo-Json -Compress)
  $locateJs = @'
(async () => {
  try {
    const params = __PARAMS_JSON__;
    if (isPageUnavailable()) return JSON.stringify({ ok:false, reason:'profile_unavailable' });
    if (isRateLimitedPage()) return JSON.stringify({ ok:false, reason:'instagram_429' });
    if (isOwnProfilePage()) return JSON.stringify({ ok:false, reason:'own_account' });
    const privateAccount = isPrivateAccount();
    if (isAlreadyFollowing()) return JSON.stringify({ ok:false, reason:'already_following', private_account: privateAccount });

    const followerInfo = getFollowerInfo();
    if (followerInfo !== null) {
      const followers = followerInfo.count;
      const detail = { followers, min_followers: params.min_followers, max_followers: params.max_followers, source: followerInfo.source, raw: followerInfo.raw };
      if (params.min_followers && followers < params.min_followers) return JSON.stringify({ ok:false, reason:'below_min_followers', detail, private_account: privateAccount });
      if (params.max_followers && followers > params.max_followers) return JSON.stringify({ ok:false, reason:'above_max_followers', detail, private_account: privateAccount });
    }

    const btn = findFollowButton();
    if (!btn) return JSON.stringify({ ok:false, reason:'follow_button_not_found', private_account: privateAccount });
    markClickTarget(btn);
    return JSON.stringify({ ok:true, private_account: privateAccount });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $locateJs = $locateJs.Replace('__PARAMS_JSON__', $paramsJson)
  $located = Eval-Cdp $ws ($JsCommon + "`n" + $locateJs)
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
  $isPrivateTarget = [bool]$located.private_account
  if (-not $located.ok) { return $located }
  Start-Sleep -Milliseconds (Get-Random -Minimum 250 -Maximum 900)
  $point = Get-FreshClickPoint $ws
  if (-not $point.ok) { return $point }
  Start-FollowNetworkCapture $ws
  Invoke-CdpHumanClick $ws ([double]$point.x) ([double]$point.y)

  $confirmPhaseTemplate = @'
(async () => {
  try {
    const end = Date.now() + __TIMEOUT__;
    while (Date.now() < end) {
      if (isAlreadyFollowing()) return JSON.stringify({ confirmed:true });
      await sleep(300);
    }
    return JSON.stringify({ confirmed:false, unchanged: !!findFollowButton() });
  } catch (err) {
    return JSON.stringify({ confirmed:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $relocateJsTemplate = @'
(async () => {
  try {
    const btn = findFollowButton();
    if (!btn) return JSON.stringify({ ok:false });
    markClickTarget(btn);
    return JSON.stringify({ ok:true });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $diagJsTemplate = @'
(async () => {
  try {
    return JSON.stringify({ detail: diagnoseFollowActionFailure() });
  } catch (err) {
    return JSON.stringify({ reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $result = Invoke-ClickConfirmWithRetry $ws $confirmPhaseTemplate $relocateJsTemplate $diagJsTemplate 'click_not_confirmed' 2000 3500
  if (-not $result.ok -and $result.reason -eq 'click_not_confirmed' -and $result.detail -and [bool]$result.detail.safeToRetry) {
    $fallbackJs = @'
(async () => {
  try {
    const btn = findFollowButton();
    if (!btn || btn.disabled || btn.getAttribute('aria-disabled') === 'true') {
      return JSON.stringify({ confirmed:false, method:'dom_click_follow', reason:'follow_button_not_ready' });
    }
    btn.focus();
    btn.click();
    const end = Date.now() + 4500;
    while (Date.now() < end) {
      if (isAlreadyFollowing()) return JSON.stringify({ confirmed:true, method:'dom_click_follow' });
      await sleep(300);
    }
    return JSON.stringify({ confirmed:false, method:'dom_click_follow', reason:'not_confirmed_after_dom_click', detail: diagnoseFollowActionFailure() });
  } catch (err) {
    return JSON.stringify({ confirmed:false, method:'dom_click_follow', reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
    $fallback = Eval-Cdp $ws ($JsCommon + "`n" + $fallbackJs)
    if ($fallback.confirmed) {
      $networkEvidence = Stop-FollowNetworkCapture $ws
      return @{ ok = $true; retried = $result.retried; fallback = $fallback.method; followNetworkRequests = $networkEvidence; private_account = $isPrivateTarget }
    }
    $detail = $result.detail
    if ($null -eq $detail) { $detail = [PSCustomObject]@{} }
    $detail | Add-Member -NotePropertyName 'followFallback' -NotePropertyValue $fallback -Force
    $result.detail = $detail
  }
  $networkEvidence = Stop-FollowNetworkCapture $ws
  if (-not $result.ok -and $networkEvidence -and $networkEvidence.Count -gt 0) {
    $detail = $result.detail
    if ($null -eq $detail) { $detail = [PSCustomObject]@{} }
    $detail | Add-Member -NotePropertyName 'followNetworkRequests' -NotePropertyValue $networkEvidence -Force
    $result.detail = $detail
  }
  $result.private_account = $isPrivateTarget
  return $result
}

function Do-Unfollow($ws, [string]$target, $settings) {
  $script:clickHitLog = @()
  if (Is-ReservedTarget $target) { return @{ ok=$false; reason='invalid_target_reserved' } }
  $cleanTarget = Clean-Username $target
  $vipList = @($settings.vip_list | ForEach-Object { Clean-Username $_ })
  if ($vipList -contains $cleanTarget) { return @{ ok=$false; reason='vip_protected' } }
  Navigate-Cdp $ws "https://www.instagram.com/$target/" 7
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
  $protectFollowers = $false
  if ($settings -and [int]$settings.protect_followers -eq 1) { $protectFollowers = $true }
  $paramsJson = (@{ protect_followers = $protectFollowers } | ConvertTo-Json -Compress)
  $locateJs = @'
(async () => {
  try {
    const params = __PARAMS_JSON__;
    if (isRateLimitedPage()) return JSON.stringify({ ok:false, reason:'instagram_429' });
    if (isPageUnavailable()) return JSON.stringify({ ok:false, reason:'profile_unavailable' });
    if (params.protect_followers && isFollowingBack()) return JSON.stringify({ ok:false, reason:'follows_back' });

    const btn = findFollowingButton() || findRequestedButton();
    if (!btn) return JSON.stringify({ ok:false, reason:'not_following' });
    markClickTarget(btn);
    return JSON.stringify({ ok:true });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $locateJs = $locateJs.Replace('__PARAMS_JSON__', $paramsJson)
  $located = Eval-Cdp $ws ($JsCommon + "`n" + $locateJs)
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
  if (-not $located.ok) { return $located }
  Start-Sleep -Milliseconds (Get-Random -Minimum 250 -Maximum 900)
  $point = Get-FreshClickPoint $ws
  if (-not $point.ok) { return $point }
  Invoke-CdpHumanClick $ws ([double]$point.x) ([double]$point.y)

  $confirmPhaseTemplate = @'
(async () => {
  try {
    const confirmBtn = await waitForUnfollowConfirmDialog(__TIMEOUT__);
    if (confirmBtn) { markClickTarget(confirmBtn); return JSON.stringify({ confirmed:true }); }
    return JSON.stringify({ confirmed:false, unchanged: !!(findFollowingButton() || findRequestedButton()) });
  } catch (err) {
    return JSON.stringify({ confirmed:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $relocateJsTemplate = @'
(async () => {
  try {
    const btn = findFollowingButton() || findRequestedButton();
    if (!btn) return JSON.stringify({ ok:false });
    markClickTarget(btn);
    return JSON.stringify({ ok:true });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $diagJsTemplate = @'
(async () => {
  try {
    return JSON.stringify({ detail: diagnoseFollowActionFailure() });
  } catch (err) {
    return JSON.stringify({ reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $locatedConfirm = Invoke-ClickConfirmWithRetry $ws $confirmPhaseTemplate $relocateJsTemplate $diagJsTemplate 'unfollow_confirm_not_found' 2000 3500
  # v5.8.1: mesmo padrao ja usado no follow (Do-Follow) - se o clique via CDP
  # (original + retry, dentro de Invoke-ClickConfirmWithRetry) nao abrir o
  # menu "Deixar de seguir" mas o estado seguir intacto (safeToRetry), tenta
  # uma 3a vez com clique nativo via DOM (btn.click()) antes de desistir e
  # ir pro proximo alvo. So dispara quando e seguro - nunca reclica se algo
  # ja mudou.
  if (-not $locatedConfirm.ok -and $locatedConfirm.reason -eq 'unfollow_confirm_not_found' -and $locatedConfirm.detail -and [bool]$locatedConfirm.detail.safeToRetry) {
    $fallbackJs = @'
(async () => {
  try {
    const btn = findFollowingButton() || findRequestedButton();
    if (!btn || btn.disabled || btn.getAttribute('aria-disabled') === 'true') {
      return JSON.stringify({ confirmed:false, method:'dom_click_unfollow', reason:'unfollow_button_not_ready' });
    }
    btn.focus();
    btn.click();
    const confirmBtn = await waitForUnfollowConfirmDialog(4500);
    if (confirmBtn) { markClickTarget(confirmBtn); return JSON.stringify({ confirmed:true, method:'dom_click_unfollow' }); }
    return JSON.stringify({ confirmed:false, method:'dom_click_unfollow', reason:'not_confirmed_after_dom_click', detail: diagnoseFollowActionFailure() });
  } catch (err) {
    return JSON.stringify({ confirmed:false, method:'dom_click_unfollow', reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
    $fallback = Eval-Cdp $ws ($JsCommon + "`n" + $fallbackJs)
    if ($fallback.confirmed) {
      $locatedConfirm = @{ ok = $true; retried = $locatedConfirm.retried; fallback = $fallback.method }
    } else {
      $detail = $locatedConfirm.detail
      if ($null -eq $detail) { $detail = [PSCustomObject]@{} }
      $detail | Add-Member -NotePropertyName 'unfollowFallback' -NotePropertyValue $fallback -Force
      $locatedConfirm.detail = $detail
    }
  }
  if (-not $locatedConfirm.ok) { return $locatedConfirm }
  Start-Sleep -Milliseconds (Get-Random -Minimum 200 -Maximum 700)
  $confirmPoint = Get-FreshClickPoint $ws
  if (-not $confirmPoint.ok) { return $confirmPoint }
  Invoke-CdpHumanClick $ws ([double]$confirmPoint.x) ([double]$confirmPoint.y)

  $confirmJs = @'
(async () => {
  try {
    const confirmed = await waitForUnfollowConfirmation(6000);
    if (confirmed) return JSON.stringify({ ok:true });
    return JSON.stringify({ ok:false, reason:'unfollow_not_confirmed', unchanged: isAlreadyFollowing(), detail: diagnoseFollowActionFailure() });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $result = Eval-Cdp $ws ($JsCommon + "`n" + $confirmJs)
  # v5.8.3: mesmo padrao de fallback DOM da 1a etapa (v5.8.1), agora tambem
  # na 2a etapa (clicar "Deixar de seguir" dentro do menu + confirmar). So
  # dispara se "unchanged" - ainda mostrando como seguindo, ou seja, o
  # primeiro clique claramente nao teve efeito. O menu pode ja ter fechado
  # depois do primeiro clique, entao o fallback reabre ele (clicando de novo
  # em "Seguindo" via DOM) antes de clicar em "Deixar de seguir".
  if (-not $result.ok -and $result.reason -eq 'unfollow_not_confirmed' -and [bool]$result.unchanged) {
    $fallbackJs = @'
(async () => {
  try {
    if (!isAlreadyFollowing()) return JSON.stringify({ confirmed:true, method:'dom_click_unfollow_confirm', note:'already_unfollowed' });
    let confirmBtn = await waitForUnfollowConfirmDialog(500);
    if (!confirmBtn) {
      const followingBtn = findFollowingButton() || findRequestedButton();
      if (!followingBtn || followingBtn.disabled || followingBtn.getAttribute('aria-disabled') === 'true') {
        return JSON.stringify({ confirmed:false, method:'dom_click_unfollow_confirm', reason:'following_button_not_ready' });
      }
      followingBtn.focus();
      followingBtn.click();
      confirmBtn = await waitForUnfollowConfirmDialog(3000);
    }
    if (!confirmBtn) {
      return JSON.stringify({ confirmed:false, method:'dom_click_unfollow_confirm', reason:'menu_not_reopened' });
    }
    confirmBtn.focus();
    confirmBtn.click();
    const confirmed = await waitForUnfollowConfirmation(4500);
    if (confirmed) return JSON.stringify({ confirmed:true, method:'dom_click_unfollow_confirm' });
    return JSON.stringify({ confirmed:false, method:'dom_click_unfollow_confirm', reason:'not_confirmed_after_dom_click', detail: diagnoseFollowActionFailure() });
  } catch (err) {
    return JSON.stringify({ confirmed:false, method:'dom_click_unfollow_confirm', reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
    $fallback = Eval-Cdp $ws ($JsCommon + "`n" + $fallbackJs)
    if ($fallback.confirmed) { return @{ ok = $true; fallback = $fallback.method } }
    $detail = $result.detail
    if ($null -eq $detail) { $detail = [PSCustomObject]@{} }
    $detail | Add-Member -NotePropertyName 'unfollowConfirmFallback' -NotePropertyValue $fallback -Force
    $result.detail = $detail
  }
  return $result
}

function Invoke-CdpHumanDoubleClick($ws, [double]$targetX, [double]$targetY) {
  Invoke-CdpHumanClick $ws $targetX $targetY
  Start-Sleep -Milliseconds (Get-Random -Minimum 60 -Maximum 140)
  Invoke-CdpMouseEvent $ws 'mousePressed' $targetX $targetY 'left' 2
  Start-Sleep -Milliseconds (Get-Random -Minimum 30 -Maximum 80)
  Invoke-CdpMouseEvent $ws 'mouseReleased' $targetX $targetY 'left' 2
}

# Re-consulta a posicao do elemento marcado com markClickTarget/data-fi-click-target
# e recalcula o ponto de clique. Chamado depois da pausa "humana", nao antes -
# assim a coordenada usada no clique real e sempre a mais recente possivel.
function Get-FreshClickPoint($ws) {
  $js = "(() => { try { return JSON.stringify(refreshMarkedClickPoint()); } catch (err) { return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) }); } })()"
  $point = Eval-Cdp $ws ($JsCommon + "`n" + $js)
  if ($point.ok -or $point.reason -eq 'click_intercepted') {
    $script:clickHitLog += ,@{
      x = $point.x
      y = $point.y
      hitMatch = $point.hitMatch
      hitTag = $point.hitTag
      hitText = $point.hitText
      reason = $point.reason
      candidateIndex = $point.candidateIndex
      allHits = $point.allHits
    }
    if ($script:clickHitLog.Count -gt 20) { $script:clickHitLog = @($script:clickHitLog | Select-Object -Last 20) }
    if ($point.reason -eq 'click_intercepted' -or $point.hitMatch -eq $false) {
      Write-AgentLog "Aviso: no ponto de clique ($([Math]::Round([double]$point.x)),$([Math]::Round([double]$point.y))) o elemento no topo era <$($point.hitTag)> '$($point.hitText)', nao o alvo esperado - possivel elemento sobreposto interceptando o clique."
    }
  }
  return $point
}

function Do-Like($ws, [string]$target) {
  $script:clickHitLog = @()
  $post = Get-FirstPost $ws $target
  if (-not $post.ok -or -not $post.href) {
    $reason = if ($post.reason) { $post.reason } else { 'post_not_found' }
    return @{ ok=$false; reason=$reason; detail=$post.detail }
  }
  $postUrl = Normalize-InstagramPostUrl ([string]$post.href)
  Navigate-Cdp $ws $postUrl 8
  Dismiss-InstagramBlockingDialogs $ws | Out-Null

  $precheckJs = @'
(async () => {
  try {
    if (isRateLimitedPage()) return JSON.stringify({ ok:false, reason:'instagram_429' });
    if (isPageUnavailable()) return JSON.stringify({ ok:false, reason:'post_not_found' });
    const scope = getPrimaryPostScope();
    if (isPrimaryPostLiked(scope)) return JSON.stringify({ ok:false, reason:'already_liked' });
    return JSON.stringify({ ok:true });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $pre = Eval-Cdp $ws ($JsCommon + "`n" + $precheckJs)
  Dismiss-InstagramBlockingDialogs $ws | Out-Null
  if (-not $pre.ok) {
    if ($pre.reason -eq 'post_not_found') { Add-BadPost $postUrl $pre.reason; Write-AgentLog "Post descartado em like @$target post=$postUrl reason=$($pre.reason)" }
    elseif ($pre.reason -eq 'instagram_429') { Write-AgentLog "Post $postUrl retornou 429 durante like em @$target. Post nao descartado (rate limit temporario)." }
    return $pre
  }

  $strategies = @(
    @{ method='strict_like_button'; find='findStrictLikeButton(scope)'; timeout=5000; double=$false },
    @{ method='main_like_button'; find="findPrimaryPostActionButton(scope, ['like'])"; timeout=6000; double=$false },
    @{ method='likely_like_button'; find='findLikelyPostLikeButton(scope)'; timeout=6000; double=$false },
    @{ method='media_double_click'; find='findPrimaryPostMedia(scope)'; timeout=6000; double=$true }
  )

  $anyCandidateFound = $false
  foreach ($strategy in $strategies) {
    $locateJs = @"
(async () => {
  try {
    const scope = getPrimaryPostScope();
    const btn = $($strategy.find);
    if (!btn || btn.hasAttribute('data-fi-tried')) return JSON.stringify({ ok:false, reason:'no_candidate' });
    btn.setAttribute('data-fi-tried', '1');
    markClickTarget(btn);
    return JSON.stringify({ ok:true });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
"@
    $located = Eval-Cdp $ws ($JsCommon + "`n" + $locateJs)
    if (-not $located.ok) { continue }
    $anyCandidateFound = $true
    Start-Sleep -Milliseconds (Get-Random -Minimum 200 -Maximum 800)
    $point = Get-FreshClickPoint $ws
    if ($point.ok) {
      if ($strategy.double) {
        Invoke-CdpHumanDoubleClick $ws ([double]$point.x) ([double]$point.y)
      } else {
        Invoke-CdpHumanClick $ws ([double]$point.x) ([double]$point.y)
      }
    }
    $confirmJs = @"
(async () => {
  try {
    const scope = getPrimaryPostScope();
    const liked = await waitForPrimaryPostLiked(scope, $($strategy.timeout));
    return JSON.stringify({ ok: liked });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
"@
    $confirmed = Eval-Cdp $ws ($JsCommon + "`n" + $confirmJs)
    if ($confirmed.ok) { return @{ ok = $true; method = $strategy.method } }
    if (-not $strategy.double) {
      $fallbackJs = @"
(async () => {
  try {
    const scope = getPrimaryPostScope();
    const btn = document.querySelector('[data-fi-click-target]');
    if (!btn || !scope || !scope.contains(btn)) return JSON.stringify({ ok:false, reason:'click_target_lost' });
    if (btn.scrollIntoView) btn.scrollIntoView({ block:'center', inline:'center' });
    humanClick(btn);
    const liked = await waitForPrimaryPostLiked(scope, $($strategy.timeout));
    return JSON.stringify({ ok: liked, method:'dom_like_fallback' });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
"@
      $fallbackConfirmed = Eval-Cdp $ws ($JsCommon + "`n" + $fallbackJs)
      if ($fallbackConfirmed.ok) {
        Write-AgentLog "Like @$target confirmado por fallback DOM apos falha/interceptacao do clique CDP."
        return @{ ok = $true; method = "$($strategy.method)_dom_fallback" }
      }
    }
  }

  $diagJs = @'
(async () => {
  try {
    const scope = getPrimaryPostScope();
    return JSON.stringify({ ok:false, detail: diagnoseLikeFailure(scope) });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $diag = Eval-Cdp $ws ($JsCommon + "`n" + $diagJs)
  $reason = if ($anyCandidateFound) { 'like_not_confirmed' } else { 'like_button_not_found' }
  $result = @{ ok=$false; reason=$reason; detail=$diag.detail }
  return $result
}

function Do-CommentConfirmWithRetry($ws, $confirmPhaseTemplate, $relocateJsTemplate, $fallbackJsTemplate, $diagJsTemplate, [int]$visibleBefore) {
  $phase1Js = $confirmPhaseTemplate.Replace('__TIMEOUT__', '2500').Replace('__VISIBLE_BEFORE__', [string]$visibleBefore)
  $state1 = Eval-Cdp $ws ($JsCommon + "`n" + $phase1Js)
  if ($state1.confirmed) { return @{ ok = $true } }

  $safeToRetry = [bool]($state1.boxAttached -and $state1.textStillPresent -and $state1.hasEnabledSubmit)
  $retried = $false
  if ($safeToRetry) {
    $relocated = Eval-Cdp $ws ($JsCommon + "`n" + $relocateJsTemplate)
    if ($relocated.ok) {
      Start-Sleep -Milliseconds (Get-Random -Minimum 200 -Maximum 500)
      $point2 = Get-FreshClickPoint $ws
      if ($point2.ok) {
        Invoke-CdpHumanClick $ws ([double]$point2.x) ([double]$point2.y)
        $retried = $true
      }
    }
  }

  $phase2Js = $confirmPhaseTemplate.Replace('__TIMEOUT__', '5500').Replace('__VISIBLE_BEFORE__', [string]$visibleBefore)
  $state2 = Eval-Cdp $ws ($JsCommon + "`n" + $phase2Js)
  if ($state2.confirmed) { return @{ ok = $true; retried = $retried } }

  $fallbackState = $null
  if ($fallbackJsTemplate -and $state2.boxAttached -and $state2.textStillPresent -and $state2.hasEnabledSubmit) {
    $fallbackJs = $fallbackJsTemplate.Replace('__VISIBLE_BEFORE__', [string]$visibleBefore)
    $fallbackState = Eval-Cdp $ws ($JsCommon + "`n" + $fallbackJs)
    if ($fallbackState.confirmed) {
      return @{ ok = $true; retried = $retried; fallback = $fallbackState.method }
    }
  }

  $diag = Eval-Cdp $ws ($JsCommon + "`n" + $diagJsTemplate)
  $detail = $diag.detail
  if ($null -eq $detail) { $detail = [PSCustomObject]@{} }
  $detail | Add-Member -NotePropertyName 'retried' -NotePropertyValue $retried -Force
  $detail | Add-Member -NotePropertyName 'safeToRetry' -NotePropertyValue $safeToRetry -Force
  $detail | Add-Member -NotePropertyName 'phase1' -NotePropertyValue $state1 -Force
  if ($fallbackState) { $detail | Add-Member -NotePropertyName 'fallback' -NotePropertyValue $fallbackState -Force }
  $detail | Add-Member -NotePropertyName 'clickHits' -NotePropertyValue $script:clickHitLog -Force
  return @{ ok = $false; reason = 'comment_not_visible_after_8s'; detail = $detail; retried = $retried }
}

function Attach-CommentPostInfo($result, [string]$postUrl) {
  if ($null -eq $result) { return $result }
  $postKey = Normalize-InstagramPostKey $postUrl
  if ($result -is [hashtable]) {
    $result.post_url = $postUrl
    $result.post_key = $postKey
    return $result
  }
  try {
    $result | Add-Member -NotePropertyName 'post_url' -NotePropertyValue $postUrl -Force
    $result | Add-Member -NotePropertyName 'post_key' -NotePropertyValue $postKey -Force
  } catch {}
  return $result
}

function Do-Comment($ws, [string]$target, $settings, $config = $null, $token = $null) {
  $script:clickHitLog = @()
  $posts = Get-CandidatePosts $ws $target 12
  if (-not $posts.ok -or -not @($posts.hrefs).Count) {
    $reason = if ($posts.reason) { $posts.reason } else { 'post_not_found' }
    return @{ ok=$false; reason=$reason; detail=$posts.detail }
  }
  $commentedPostKeys = New-Object 'System.Collections.Generic.HashSet[string]'
  if ($config -and $token) {
    $commentedPostKeys = Get-CommentedPostKeys $config $token $target
  }
  $templates = @($settings.comment_templates)
  $baseComment = if ($templates.Count) { $templates[(Get-Random -Minimum 0 -Maximum $templates.Count)] } else { 'Muito bom!' }
  $extraCount = Get-Random -Minimum 2 -Maximum 4
  $extras = @($script:CommentExtras | Sort-Object { Get-Random } | Select-Object -First $extraCount)
  $comment = "$baseComment $($extras -join ' ')".Trim()
  if ($comment.Length -gt 220) { $comment = $comment.Substring(0, 220).Trim() }
  $commentJson = $comment | ConvertTo-Json -Compress
  $ownCommenterJson = (Clean-Username $settings.account_username) | ConvertTo-Json -Compress
  $locateJs = @'
(async () => {
  try {
    const commentText = __COMMENT_JSON__;
    const ownCommenter = __OWN_COMMENTER_JSON__;
    if (isRateLimitedPage()) return JSON.stringify({ ok:false, reason:'instagram_429' });
    if (isPageUnavailable()) return JSON.stringify({ ok:false, reason:'post_not_found' });
    const scope = getPrimaryPostScope();
    const ownComment = findOwnCommentInfo(scope, ownCommenter);
    if (ownComment && ownComment.found) return JSON.stringify({ ok:false, reason:'already_commented_post', detail: ownComment });
    const box = await findCommentBox(scope, 7000);
    if (!box) return JSON.stringify({ ok:false, reason:'comment_box_not_found', detail: diagnoseCommentFailure(null, scope) });

    const inserted = await writeCommentText(box, commentText);
    if (!inserted) return JSON.stringify({ ok:false, reason:'comment_text_not_inserted', detail: diagnoseCommentFailure(box, scope) });

    const submitResult = await waitForSubmitCommentButton(box, 7000, scope, commentText);
    const submit = submitResult.submit;
    const liveBox = submitResult.box || box;
    if (!submit) return JSON.stringify({ ok:false, reason:'comment_submit_not_found', detail: diagnoseCommentFailure(liveBox, scope) });

    const visibleBefore = countVisibleCommentText(commentText, liveBox, scope);
    liveBox.setAttribute('data-fi-comment-box', '1');
    markClickTarget(submit);
    return JSON.stringify({ ok:true, visibleBefore });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $locateJs = $locateJs.Replace('__COMMENT_JSON__', $commentJson)
  $locateJs = $locateJs.Replace('__OWN_COMMENTER_JSON__', $ownCommenterJson)

  # v5.5.0: antes disto, um unico clique era seguido de uma espera bloqueante
  # de 8s inteira - se o clique nao tivesse efeito nenhum (visto nos logs de
  # 01/07: caixa intacta com o texto original, botao "Postar" ainda presente
  # e habilitado, mesmo apos os 8s), a acao so falhava, sem tentar de novo.
  # Agora dividimos em duas fases curtas: espera 2.5s pelo primeiro clique;
  # se nao confirmar E o estado mostrar que nada mudou (caixa ainda anexada,
  # texto original intacto, botao ainda habilitado - ie. o clique claramente
  # nao teve efeito), reclicamos numa posicao recalculada na hora. So entao
  # esperamos o resto do prazo. Nunca reclicamos se o estado mudou de
  # qualquer forma, pra nao arriscar comentario duplicado.
  $confirmPhaseTemplate = @'
(async () => {
  try {
    const commentText = __COMMENT_JSON__;
    const scope = getPrimaryPostScope();
    const liveBox = scope.querySelector('[data-fi-comment-box]') || document.querySelector('[data-fi-comment-box]');
    const state = await waitForCommentVisibleWithState(commentText, liveBox, scope, __TIMEOUT__, __VISIBLE_BEFORE__);
    return JSON.stringify(state);
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $confirmPhaseTemplate = $confirmPhaseTemplate.Replace('__COMMENT_JSON__', $commentJson)

  $relocateJsTemplate = @'
(async () => {
  try {
    const commentText = __COMMENT_JSON__;
    const scope = getPrimaryPostScope();
    const box = document.querySelector('[data-fi-comment-box]');
    const found = await waitForSubmitCommentButton(box, 1500, scope, commentText);
    if (!found.submit) return JSON.stringify({ ok:false });
    if (found.box) { found.box.setAttribute('data-fi-comment-box', '1'); }
    markClickTarget(found.submit);
    return JSON.stringify({ ok:true });
  } catch (err) {
    return JSON.stringify({ ok:false, reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $relocateJsTemplate = $relocateJsTemplate.Replace('__COMMENT_JSON__', $commentJson)

  $fallbackJsTemplate = @'
(async () => {
  try {
    const commentText = __COMMENT_JSON__;
    const scope = getPrimaryPostScope();
    const box = scope.querySelector('[data-fi-comment-box]') || document.querySelector('[data-fi-comment-box]');
    if (!box || !document.contains(box) || !editableContainsText(box, commentText)) {
      return JSON.stringify({ confirmed:false, method:'dom_click_submit', reason:'comment_box_not_ready' });
    }
    const found = await waitForSubmitCommentButton(box, 1500, scope, commentText);
    if (!found.submit || found.submit.disabled || found.submit.getAttribute('aria-disabled') === 'true') {
      return JSON.stringify({ confirmed:false, method:'dom_click_submit', reason:'submit_not_ready' });
    }
    if (found.box) { found.box.setAttribute('data-fi-comment-box', '1'); }
    found.submit.focus();
    found.submit.click();
    const state = await waitForCommentVisibleWithState(commentText, found.box || box, scope, 3500, __VISIBLE_BEFORE__);
    state.method = 'dom_click_submit';
    return JSON.stringify(state);
  } catch (err) {
    return JSON.stringify({ confirmed:false, method:'dom_click_submit', reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $fallbackJsTemplate = $fallbackJsTemplate.Replace('__COMMENT_JSON__', $commentJson)

  $diagJsTemplate = @'
(async () => {
  try {
    const scope = getPrimaryPostScope();
    const liveBox = scope.querySelector('[data-fi-comment-box]') || document.querySelector('[data-fi-comment-box]');
    return JSON.stringify({ detail: diagnoseCommentFailure(liveBox, scope) });
  } catch (err) {
    return JSON.stringify({ reason:'js_exception', detail:String((err && err.message) || err) });
  }
})()
'@
  $lastResult = $null
  foreach ($href in @($posts.hrefs | Select-Object -First 12)) {
    if (-not $href) { continue }
    $postUrl = Normalize-InstagramPostUrl ([string]$href)
    if (-not $postUrl -or $script:badPostSet.Contains($postUrl)) { continue }
    $postKey = Normalize-InstagramPostKey $postUrl
    if ($postKey -and $commentedPostKeys.Contains($postKey)) {
      Write-AgentLog "Post ja comentado registrado na tabela para @$target ($postUrl). Pulando sem abrir."
      continue
    }
    Navigate-Cdp $ws $postUrl 8
    Dismiss-InstagramBlockingDialogs $ws | Out-Null
    $located = Eval-Cdp $ws ($JsCommon + "`n" + $locateJs)
    Dismiss-InstagramBlockingDialogs $ws | Out-Null
    if (-not $located.ok) {
      $result = $located
    } else {
      Start-Sleep -Milliseconds (Get-Random -Minimum 250 -Maximum 850)
      $point = Get-FreshClickPoint $ws
      if (-not $point.ok) {
        $result = $point
      } else {
        Invoke-CdpHumanClick $ws ([double]$point.x) ([double]$point.y)
        $visibleBefore = [int]$located.visibleBefore
        $result = Do-CommentConfirmWithRetry $ws $confirmPhaseTemplate $relocateJsTemplate $fallbackJsTemplate $diagJsTemplate $visibleBefore
      }
    }
    $result = Attach-CommentPostInfo $result $postUrl
    $lastResult = $result
    if ($result.ok) { return $result }
    if ($result.reason -in @('comment_box_not_found','comment_submit_not_found','post_not_found','already_commented_post')) {
      Add-BadPost $postUrl $result.reason
    }
    if ($result.reason -eq 'already_commented_post' -and $config -and $token) {
      try {
        ApiPost $config $token '/api/agents/log' @{
          type='comment'; target=$target; status='skipped'; reason='already_commented_post';
          post_url=$result.post_url; post_key=$result.post_key
        } | Out-Null
        if ($result.post_key) { [void]$commentedPostKeys.Add([string]$result.post_key) }
        $result | Add-Member -NotePropertyName 'already_recorded' -NotePropertyValue $true -Force
      } catch {
        Write-AgentLog "Não foi possível salvar post já comentado para @$target ($postUrl): $($_.Exception.Message)"
      }
    }
    if ($result.reason -notin @('comment_box_not_found','comment_submit_not_found','post_not_found','already_commented_post','click_target_lost')) {
      return $result
    }
    Write-AgentLog "Post sem campo/botão de comentário para @$target ($postUrl): $($result.reason). Tentando próximo post."
  }
  if ($lastResult) { return $lastResult }
  return @{ ok=$false; reason='all_candidate_posts_already_commented' }
}

function Is-BenignActionReason([string]$reason) {
  $benignReasons = @('already_following','already_liked','own_account','private_account','below_min_followers','above_max_followers','vip_protected','follows_back','not_following','profile_unavailable','invalid_target_reserved','unfollow_repeated_failure','instagram_429','post_not_found','comment_box_not_found','comment_submit_not_found','commentable_post_not_found','already_commented_post','all_candidate_posts_already_commented')
  return $benignReasons -contains $reason
}

function Record-Action($config, $token, [string]$type, [string]$target, $result) {
  if ($result.already_recorded -and -not $result.reservation_id) { return }
  $ok = [bool]$result.ok
  $reason = if ($result.reason) { $result.reason } else { if ($ok) { '' } else { 'agent_action_failed' } }
  # If we discovered on the page that we already follow/like this target (not
  # via our own click this round), the history still needs to know about it -
  # otherwise the same already-followed/liked person keeps getting picked as a
  # candidate forever, wasting a full round every time it resurfaces.
  $discoveredExisting = ($type -eq 'follow' -and $reason -eq 'already_following') -or ($type -eq 'like' -and $reason -eq 'already_liked')
  $status = if ($ok -or $discoveredExisting) { 'ok' } elseif (Is-BenignActionReason $reason) { 'skipped' } else { 'failed' }
  $body = @{ type=$type; target=$target; status=$status; reason=$reason }
  if ($result.reservation_id) { $body.reservation_id = [string]$result.reservation_id }
  if ($type -eq 'comment') {
    if ($result.post_url) { $body.post_url = [string]$result.post_url }
    if ($result.post_key) { $body.post_key = [string]$result.post_key }
  }
  ApiPost $config $token '/api/agents/log' $body | Out-Null
}

function Get-UnfollowTarget($config, $token) {
  try {
    $res = ApiGet $config $token '/api/agents/targets/unfollow'
    foreach ($target in @($res.targets)) {
      $clean = Clean-Username $target
      if ($clean -and -not (Is-ReservedTarget $clean)) { return $clean }
      if ($clean) { Write-AgentLog "Unfollow ignorou alvo reservado vindo do servidor: @$clean" }
    }
  } catch {}
  return ''
}

function Get-CommentedPostKeys($config, $token, [string]$target) {
  $set = New-Object 'System.Collections.Generic.HashSet[string]'
  try {
    $cleanTarget = Clean-Username $target
    $path = '/api/agents/targets/commented-posts'
    if ($cleanTarget) { $path += "?target=$([Uri]::EscapeDataString($cleanTarget))" }
    $res = ApiGet $config $token $path
    foreach ($key in @($res.keys)) {
      $cleanKey = ([string]$key).Trim()
      if ($cleanKey) { [void]$set.Add($cleanKey) }
    }
    foreach ($post in @($res.posts)) {
      $key = Normalize-InstagramPostKey ([string]$post.url)
      if ($key) { [void]$set.Add($key) }
    }
  } catch {
    Write-AgentLog "Não foi possível obter posts comentados para @${target}: $($_.Exception.Message)"
  }
  return $set
}

# Pulls the lists of targets already followed/liked/commented (successfully)
# for this account from the panel, so the same person is never targeted
# twice for the same action. Refreshed once per round (every loop iteration).
function Refresh-HistorySets($config, $token) {
  $script:followedSet = New-Object 'System.Collections.Generic.HashSet[string]'
  $script:likedSet = New-Object 'System.Collections.Generic.HashSet[string]'
  $script:commentedSet = New-Object 'System.Collections.Generic.HashSet[string]'
  try {
    $res = ApiGet $config $token '/api/agents/targets/followed'
    foreach ($t in @($res.targets)) { [void]$script:followedSet.Add((Clean-Username $t)) }
  } catch { Write-AgentLog "Não foi possível obter targets/followed: $($_.Exception.Message)" }
  try {
    $res = ApiGet $config $token '/api/agents/targets/liked'
    foreach ($t in @($res.targets)) { [void]$script:likedSet.Add((Clean-Username $t)) }
  } catch { Write-AgentLog "Não foi possível obter targets/liked: $($_.Exception.Message)" }
  try {
    $res = ApiGet $config $token '/api/agents/targets/commented'
    foreach ($t in @($res.targets)) { [void]$script:commentedSet.Add((Clean-Username $t)) }
  } catch { Write-AgentLog "Não foi possível obter targets/commented: $($_.Exception.Message)" }
}

# Given a target and the list of actions we would like to run this round,
# returns only the ones not already done for that specific target. Example:
# already followed @joao but never liked/commented -> only like+comment remain.
function Get-PendingActionsForTarget([string]$target, [string[]]$actions) {
  $clean = Clean-Username $target
  $isKnownPrivate = $script:privateAccountSet -and $script:privateAccountSet.Contains($clean)
  $pending = @()
  foreach ($a in $actions) {
    $done = $false
    if ($a -eq 'follow' -and $script:followedSet -and $script:followedSet.Contains($clean)) { $done = $true }
    elseif ($a -eq 'like' -and $script:likedSet -and $script:likedSet.Contains($clean)) { $done = $true }
    elseif ($a -eq 'comment' -and $script:commentedSet -and $script:commentedSet.Contains($clean)) { $done = $true }
    # v5.8.14: perfil ja confirmado privado nesta execucao - like/comment
    # nunca vao funcionar (nao da pra ver o post), entao nem entram na fila.
    # Follow continua elegivel (pode mandar solicitacao de seguir).
    elseif (($a -eq 'like' -or $a -eq 'comment') -and $isKnownPrivate) { $done = $true }
    if (-not $done) { $pending += $a }
  }
  return $pending
}

function Get-NextTarget($ws, $settings, [string[]]$desiredActions) {
  $script:lastTargetSearchTrace = @()
  for ($i = 0; $i -lt 14; $i++) {
    if (-not $script:targetQueue -or $script:targetQueue.Count -eq 0) {
      $beforeSource = $script:lastSourceInfo
      $collected = @(Collect-Targets $ws $settings | Where-Object { -not (Is-ReservedTarget $_) })
      $source = $script:lastSourceInfo
      $script:lastTargetSearchTrace += "$($source.type):$($source.label) candidatos=$($collected.Count)"
      $script:targetQueue = @($collected)
      if (-not $script:targetQueue -or $script:targetQueue.Count -eq 0) { continue }
    }
    while ($script:targetQueue.Count -gt 0) {
      $target = Clean-Username $script:targetQueue[0]
      $script:targetQueue = @($script:targetQueue | Select-Object -Skip 1)
      if (Is-ReservedTarget $target) { continue }
      $pending = @(Get-PendingActionsForTarget $target $desiredActions)
      if ($pending.Count -gt 0) { return @{ target=$target; actions=$pending } }
      $script:lastTargetSearchTrace += "ignorado @$target ja_feito=$($desiredActions -join '+')"
    }
  }
  return @{ target=''; actions=@() }
}

function Print-Dashboard($configData, [string]$state, [string]$detail) {
  Clear-Host
  $settings = $configData.settings
  $account = $configData.account
  Write-Host 'FlowInsta BR Agent' -ForegroundColor Yellow
  Write-Host "Versão: $AgentVersion | Conta: @$($account.username) | Status: $state"
  Write-Host "Detalhe: $detail"
  Write-Host ''
  Write-Host "Ações: $(@($settings.actions_enabled) -join ' + ') | Horário: $($settings.time_start)-$($settings.time_end) | Velocidade: $($settings.speed)"
  Write-Host "Hoje: follow $(DailyCount $configData 'follow')/$($settings.follows_per_day) | like $(DailyCount $configData 'like')/$($settings.likes_per_day) | comment $(DailyCount $configData 'comment')/$($settings.comments_per_day) | unfollow $(DailyCount $configData 'unfollow')/$($settings.unfollows_per_day)"
  Write-Host ''
  Write-Host 'Últimas ações:'
  foreach ($item in @($configData.recent | Select-Object -First 8)) {
    Write-Host " - $($item.created_at) $($item.type) @$($item.target) $($item.status) $($item.reason)"
  }
  Write-Host ''
  Write-Host 'Deixe esta janela e o navegador do Agent abertos. Para parar, pause no painel ou feche esta janela.'
}

function Check-AgentUpdate($config) {
  $latest = Invoke-RestMethod -Uri "$($config.server)/api/agents/latest?platform=windows"
  if (-not $latest.downloadUrl -or ([version]$latest.version -le [version]$AgentVersion)) { return }
  $statusPath = Join-Path $root 'update-status.txt'
  Set-Content -LiteralPath $statusPath -Value 'pending' -Encoding ASCII
  Clear-Host
  Write-Host 'FlowInsta BR Agent' -ForegroundColor Yellow
  Write-Host "Atualizando da versao $AgentVersion para a $($latest.version)..." -ForegroundColor Yellow
  Write-Host 'Aguarde. O Agent vai instalar e reabrir sozinho nesta janela.'
  Write-AgentLog "Atualizando de $AgentVersion para $($latest.version)"
  $url = if ($latest.downloadUrl -match '^https?://') { $latest.downloadUrl } else { "$($config.server)$($latest.downloadUrl)" }
  $zip = Join-Path $env:TEMP "flowinsta-agent-$($latest.version).zip"
  Invoke-WebRequest -Uri $url -OutFile $zip
  $updaterArgs = @(
    '-NoProfile', '-ExecutionPolicy', 'Bypass',
    '-File', (Quote-BrowserArg (Join-Path $root 'updater.ps1')),
    '-AgentPid', $PID,
    '-ZipPath', (Quote-BrowserArg $zip),
    '-InstallDir', (Quote-BrowserArg $root)
  ) -join ' '
  Start-Process powershell.exe -WindowStyle Hidden -ArgumentList $updaterArgs
  exit 22
}

function Send-CommandResult($config, $token, $command, [bool]$ok, [string]$message) {
  try {
    ApiPost $config $token "/api/agents/commands/$($command.id)/result" @{ ok=$ok; message=$message } | Out-Null
  } catch {}
}

function Process-Commands($config, $token, $commands, $configData) {
  foreach ($command in @($commands)) {
    try {
      switch ($command.command) {
        'open_instagram' {
          $script:paused = $false
          Start-AgentBrowser $configData.account.username
        }
        'pause' {
          $script:paused = $true
          Stop-AgentBrowser
        }
        'resume' {
          $script:paused = $false
          Start-AgentBrowser $configData.account.username
        }
        'restart' {
          Stop-AgentBrowser
          Start-Sleep -Seconds 2
          Start-AgentBrowser $configData.account.username
        }
        'check_update' { Check-AgentUpdate $config }
      }
      Send-CommandResult $config $token $command $true 'Executado'
    } catch {
      Send-CommandResult $config $token $command $false $_.Exception.Message
    }
  }
}

try {
  $config = if (Test-Path -LiteralPath $configPath) { Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json } else { Register-Agent }
  $token = Unprotect-Token $config.token
  $script:config = $config
  $script:token = $token
  Load-BadPostCache
  $lastUpdateCheck = [DateTime]::MinValue

  while ($true) {
    try {
      if ((Get-Date) - $lastUpdateCheck -gt [TimeSpan]::FromMinutes(10)) {
        Check-AgentUpdate $config
        $lastUpdateCheck = Get-Date
      }
      $cfg = ApiGet $config $token '/api/agents/config'
      $script:lastConfig = $cfg
      Start-AgentBrowser $cfg.account.username
      $heartbeat = ApiPost $config $token '/api/agents/heartbeat' @{ version=$AgentVersion; status='running'; last_error=''; action_blocks=(Get-ActiveActionBlocksForReport) }
      Process-Commands $config $token $heartbeat.commands $cfg
      Refresh-HistorySets $config $token
      Print-Dashboard $cfg 'Online' 'Sincronizado com o painel'

      if (-not $cfg.settings.running) {
        Print-Dashboard $cfg 'Pausado' 'A automação está pausada no painel'
        Start-Sleep -Seconds 30
        continue
      }
      if (-not (Is-WithinSchedule $cfg.settings)) {
        $minutesLeft = Get-MinutesUntilScheduleOpens $cfg.settings
        $sleepSeconds = [Math]::Min($minutesLeft * 60, 15 * 60)
        Print-Dashboard $cfg 'Aguardando' "Fora do horário configurado (abre em $minutesLeft min)"
        Start-Sleep -Seconds $sleepSeconds
        continue
      }

      $blockSeconds = Get-ActionBlockCooldownSeconds
      if ($blockSeconds -gt 0) {
        $sleepSeconds = [Math]::Min($blockSeconds, 15 * 60)
        Print-Dashboard $cfg 'Aguardando' "Automacao pausada por suspeita de bloqueio da Instagram (nova tentativa em $([Math]::Ceiling($blockSeconds / 60)) min)"
        Start-Sleep -Seconds $sleepSeconds
        continue
      }

      $action = Pick-Action $cfg
      if (-not $action) {
        Print-Dashboard $cfg 'Aguardando' 'Limites diarios atingidos'
        Start-Sleep -Seconds 300
        continue
      }

      $ws = New-CdpSocket
      try {
        Invoke-Cdp $ws 'Runtime.enable' @{} | Out-Null
        if ($action -eq 'unfollow') {
          $unfTarget = Get-UnfollowTarget $config $token
          if ($unfTarget) {
            $pick = @{ target = $unfTarget; actions = @('unfollow') }
          } else {
            ApiPost $config $token '/api/agents/log' @{ type='unfollow'; target='sem_alvo'; status='skipped'; reason='no_unfollow_target_eligible' } | Out-Null
            $fallbackAction = Pick-NormalAction $cfg
            if ($fallbackAction) {
              $action = if ($cfg.settings.action_mode -eq 'all_per_contact') { 'all_per_contact' } else { $fallbackAction }
              $desired = @(ActionsForTarget $cfg $action)
              $pick = Get-NextTarget $ws $cfg.settings $desired
            } else {
              $pick = @{ target = ''; actions = @() }
            }
          }
        } else {
          $desired = @(ActionsForTarget $cfg $action)
          $pick = Get-NextTarget $ws $cfg.settings $desired
        }
        $target = $pick.target
        $actionsToRun = @($pick.actions)
        if (-not $target -or $actionsToRun.Count -eq 0) {
          if ($pick.reason -eq 'instagram_429' -or $pick.reason -eq 'instagram_restriction') {
            Wait-InstagramRateLimitCooldown $cfg "Instagram sinalizou restricao durante a busca de alvos ($($pick.reason)). Pausando e reiniciando o navegador do Agent."
            continue
          }
          $src = $script:lastSourceInfo
          $reason = "no_target_found source=$($src.type):$($src.label)"
          $trace = (@($script:lastTargetSearchTrace) -join ' | ')
          Write-AgentLog "$action sem_alvo skipped $reason trace=$trace"
          Print-Dashboard $cfg 'Aguardando' 'Nenhum alvo novo encontrado agora. Alternando fontes e tentando novamente em 15s.'
          Start-Sleep -Seconds 15
          continue
        }

        $lastSummary = ''
        $skipRound = $false
        foreach ($runAction in $actionsToRun) {
          try {
            $freshCfg = ApiGet $config $token '/api/agents/config'
            $script:lastConfig = $freshCfg
            if (-not (Is-ActionStillAllowed $freshCfg $runAction)) {
              Write-AgentLog "$runAction @${target} cancelado antes de executar: configuracao mudou ou bloqueio ativo."
              $skipRound = $true
              break
            }
            $cfg = $freshCfg
          } catch {
            Write-AgentLog "Nao consegui reconferir configuracao antes de $runAction @${target}: $($_.Exception.Message)"
          }
          $cleanRunTarget = Clean-Username $target
          if (($runAction -eq 'like' -or $runAction -eq 'comment') -and $script:privateAccountSet.Contains($cleanRunTarget)) {
            # v5.8.14: perfil ja confirmado privado (descoberto agora mesmo
            # pelo follow desta rodada, ou em rodada anterior) - pula direto,
            # sem gastar outra navegacao so pra redescobrir o obvio.
            $result = @{ ok = $false; reason = 'private_account'; private_account = $true }
          } else {
            $reservation = Reserve-AgentAction $config $token $runAction $target
            if (-not $reservation.allowed) {
              Write-AgentLog "$runAction @${target} cancelado pelo limite global: $($reservation.reason)"
              $skipRound = $true
              break
            }
            Print-Dashboard $cfg 'Rodando' "Executando $runAction em @$target"
            if ($runAction -eq 'follow') { Wait-FollowExtraSpacing }
            $result = Invoke-AgentAction $ws $runAction $target $cfg.settings $config $token
            $result | Add-Member -NotePropertyName reservation_id -NotePropertyValue ([string]$reservation.reservation_id) -Force
          }
          if ($result.private_account -or $result.reason -eq 'private_account') {
            [void]$script:privateAccountSet.Add($cleanRunTarget)
          }
          if ($result.ok) { $script:consecutiveRateLimitHits = 0 }
          if ($result.ok) {
            $script:notConfirmedStreak[$runAction] = 0
          } elseif ($script:NotConfirmedReasonByAction.ContainsKey($runAction) -and $result.reason -eq $script:NotConfirmedReasonByAction[$runAction]) {
            $script:notConfirmedStreak[$runAction]++
            if ($script:notConfirmedStreak[$runAction] -ge $script:NOT_CONFIRMED_STREAK_LIMIT) {
              $script:actionBlockedUntil[$runAction] = (Get-Date).AddMinutes($script:ACTION_BLOCK_COOLDOWN_MINUTES)
              $script:notConfirmedStreak[$runAction] = 0
              Write-AgentLog "'$runAction' falhou em confirmar $($script:NOT_CONFIRMED_STREAK_LIMIT)x seguidas em alvos diferentes - provavel bloqueio da Instagram. Automacao inteira pausada ate $($script:actionBlockedUntil[$runAction].ToString('HH:mm:ss'))."
              $skipRound = $true
            }
          } else {
            # Motivo diferente (already_liked, button_not_found, etc.) nao e
            # sinal de bloqueio - so reseta a contagem.
            $script:notConfirmedStreak[$runAction] = 0
          }
          if ($runAction -eq 'unfollow' -and $result.ok) {
            $script:lastUnfollowFailureTarget = ''
            $script:lastUnfollowFailureReason = ''
            $script:lastUnfollowFailureCount = 0
          }
          if ($runAction -eq 'unfollow' -and -not $result.ok -and $result.reason -in @('unfollow_confirm_not_found','unfollow_not_confirmed')) {
            $cleanFailureTarget = Clean-Username $target
            if ($cleanFailureTarget -and $script:lastUnfollowFailureTarget -eq $cleanFailureTarget -and $script:lastUnfollowFailureReason -eq $result.reason) {
              $script:lastUnfollowFailureCount++
            } else {
              $script:lastUnfollowFailureTarget = $cleanFailureTarget
              $script:lastUnfollowFailureReason = [string]$result.reason
              $script:lastUnfollowFailureCount = 1
            }
            if ($script:lastUnfollowFailureCount -ge 3) {
              Write-AgentLog "Unfollow de @$target falhou 3 vezes seguidas ($($result.reason)) - marcando como pulado pra nao travar a fila."
              $result.reason = 'unfollow_repeated_failure'
            }
          }
          Record-Action $config $token $runAction $target $result
          $logLine = "$runAction @$target ok=$($result.ok) reason=$($result.reason)"
          if ($result.detail) {
            try { $logLine += " detail=$($result.detail | ConvertTo-Json -Compress -Depth 6)" }
            catch { $logLine += " detail_conversion_failed=$($_.Exception.Message)" }
          }
          Write-AgentLog $logLine
          $actionStatus = if ($result.ok) { 'ok' } elseif (Is-BenignActionReason $result.reason) { "pulado $($result.reason)" } else { "falhou $($result.reason)" }
          $lastSummary = "$runAction @$target $actionStatus"
          if ($skipRound -and (Get-ActionBlockCooldownSeconds) -gt 0) { break }
          if (-not $result.ok -and ($result.reason -eq 'instagram_429' -or $result.reason -eq 'instagram_restriction')) {
            Wait-InstagramRateLimitCooldown $cfg "Instagram sinalizou restricao ao executar $runAction em @$target ($($result.reason)). Pausando e reiniciando o navegador do Agent."
            $skipRound = $true
            break
          }
          if (-not $result.ok -and $result.reason -in @('invalid_target_reserved','profile_unavailable','follow_button_not_found','post_not_found','already_following','private_account','own_account','not_following','unfollow_confirm_not_found','unfollow_not_confirmed','unfollow_repeated_failure')) {
            Recover-InstagramPage $ws $cfg.settings $result.reason
            Print-Dashboard $cfg 'Aguardando' "Alvo @$target ignorado: $($result.reason). Buscando outro em 15s."
            Start-Sleep -Seconds 15
            $skipRound = $true
            break
          }
          if ($actionsToRun.Count -gt 1) { Start-Sleep -Seconds (Get-AutomationDelaySeconds $cfg.settings) }
        }
        if (-not $skipRound) {
          $script:nextRoundDelaySeconds = Get-AutomationDelaySeconds $cfg.settings
          Print-Dashboard $cfg 'Aguardando' "Última ação: $lastSummary. Próxima rodada em $($script:nextRoundDelaySeconds)s."
        }
      } finally {
        if ($ws -and $ws -is [Net.WebSockets.ClientWebSocket]) { $ws.Dispose() }
      }

      if ($skipRound) { continue }
      $delaySeconds = if ($script:nextRoundDelaySeconds) { $script:nextRoundDelaySeconds } else { Get-AutomationDelaySeconds $cfg.settings }
      Start-Sleep -Seconds $delaySeconds
    } catch {
      Write-AgentLog "Loop: $($_.Exception.Message)"
      if ($script:lastConfig) { Print-Dashboard $script:lastConfig 'Erro' $_.Exception.Message }
      try { ApiPost $config $token '/api/agents/heartbeat' @{ version=$AgentVersion; status='attention'; last_error=$_.Exception.Message } | Out-Null } catch {}
      Start-Sleep -Seconds 30
    }
  }
} catch {
  Write-AgentLog "Fatal: $($_.Exception.Message)"
  [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'FlowInsta BR Agent') 2>$null
  exit 1
}


