private/localization/Convert-CtResultText.ps1

<#
.SYNOPSIS
    Tradutor pt-BR do texto de RESULTADO dos testes, por dicionário de padrões (offline).
 
.DESCRIPTION
    O texto de resultado é gerado dinamicamente pela lógica de cada teste, a partir
    de templates fixos com valores do cliente interpolados. Traduzir esses literais
    na fonte é inseguro: os mesmos tokens (ex.: 'Enabled', 'Pass') também são usados
    em comparações lógicas contra dados da API. Por isso a tradução do resultado é
    feita aqui, em tempo de execução, sobre o texto JÁ RENDERIZADO — depois que toda
    a lógica de comparação rodou.
 
    Estratégia (baseada no dicionário assets/result-dictionary.json):
      1. Entradas exatas (sem placeholder) -> hashtable, busca O(1).
      2. Entradas com placeholders {0},{1}... -> regex com grupos de captura,
         indexadas pelo prefixo literal para não testar milhares de regex por linha.
      3. Traduz linha a linha preservando os VALORES do cliente capturados.
 
    Fallback seguro: linha sem correspondência permanece em inglês.
 
    Adaptado do ResultTranslator.ps1 (Cloud Target). Inicialização preguiçosa:
    a primeira chamada a Convert-CtResultText carrega o dicionário dos assets.
#>


$script:CtExact    = @{}   # texto EN exato -> PT
$script:CtByPrefix = @{}   # prefixo (8 chars, minúsculo) -> lista de padrões
$script:CtNoPrefix = @()   # padrões que começam com placeholder
$script:CtCell     = @{}   # termos de célula de tabela (lista fechada)
$script:CtLoaded   = $false

function Initialize-CtResultTranslator {
    [CmdletBinding()]
    param([string]$DictionaryPath)

    if (-not $DictionaryPath) {
        $DictionaryPath = Join-Path $script:ModuleRoot 'assets/result-dictionary.json'
    }
    $script:CtExact = @{}; $script:CtByPrefix = @{}; $script:CtNoPrefix = @(); $script:CtLoaded = $false

    # Termos de célula de tabela. Lista fechada e curta: só entra o que NUNCA é
    # dado do cliente (status, sim/não, estados de política).
    $script:CtCell = @{
        'Pass' = 'Aprovado'; '✅ Pass' = '✅ Aprovado'; '✅ Passed' = '✅ Aprovado'
        'Fail' = 'Reprovado'; '❌ Fail' = '❌ Reprovado'; '❌ Failed' = '❌ Reprovado'
        'Enabled' = 'Habilitado'; '✅ Enabled' = '✅ Habilitado'; '❌ Enabled' = '❌ Habilitado'
        'Disabled' = 'Desabilitado'; '❌ Disabled' = '❌ Desabilitado'; '✅ Disabled' = '✅ Desabilitado'
        'Yes' = 'Sim'; '✅ Yes' = '✅ Sim'; '❌ Yes' = '❌ Sim'
        'No' = 'Não'; '✅ No' = '✅ Não'; '❌ No' = '❌ Não'
        'None' = 'Nenhum'; 'Unknown' = 'Desconhecido'
        'Not configured' = 'Não configurado'; '❌ Not configured' = '❌ Não configurado'
        'Not applicable' = 'Não aplicável'; 'Not found' = 'Não encontrado'
        'Investigate' = 'Investigar'; '⚠️ Investigate' = '⚠️ Investigar'
        'Valid' = 'Válido'; 'Expired' = 'Vencido'; 'Skipped' = 'Ignorado'
        'Allowed' = 'Permitido'; '✅ Allowed' = '✅ Permitido'
        'Blocked' = 'Bloqueado'; '❌ Blocked' = '❌ Bloqueado'
        'Compliant' = 'Conforme'; 'Non-compliant' = 'Não conforme'
        'Covered' = 'Coberto'; '✅ Covered' = '✅ Coberto'
        'Member' = 'Membro'; 'Guest' = 'Convidado'; 'User' = 'Usuário'; 'Group' = 'Grupo'
        'Report-only' = 'Somente relatório'; 'Prevention' = 'Prevention'
        'No data available' = 'Nenhum dado disponível'
        'Permanent' = 'Permanente'; 'Eligible' = 'Elegível'; 'Active' = 'Ativo'
    }
    if (-not (Test-Path $DictionaryPath)) { $script:CtLoaded = $true; return 0 }

    $dic = Get-Content $DictionaryPath -Raw -Encoding UTF8 | ConvertFrom-Json
    $n = 0
    foreach ($p in $dic.entries.PSObject.Properties) {
        $en = $p.Name; $pt = $p.Value
        if ([string]::IsNullOrWhiteSpace($pt)) { continue }
        $n++
        if ($en -notmatch '\{\d+\}') {
            $script:CtExact[$en] = $pt
            # o vendor usa '%TestResult%' como marcador de concatenação; a linha
            # renderizada pode chegar sem ele. Registra a variante enxuta também.
            if ($en.EndsWith('%TestResult%')) {
                $bare = $en.Substring(0, $en.Length - 12).TrimEnd()
                if ($bare -and -not $script:CtExact.ContainsKey($bare)) {
                    $script:CtExact[$bare] = $pt.Replace('%TestResult%', '').TrimEnd()
                }
            }
            continue
        }
        # padrão com placeholders -> regex ancorado, placeholders viram capturas.
        # Atenção: [regex]::Escape escapa '{' mas NÃO escapa '}'.
        $rx = [regex]::Escape($en) -replace '\\\{(\d+)\}', '(.*?)'
        $entry = [pscustomobject]@{
            Regex = [regex]::new('^' + $rx + '$', 'Compiled')
            Pt    = $pt
            Len   = $en.Length
        }
        $idx = $en.IndexOf('{')
        $prefix = if ($idx -ge 1) { $en.Substring(0, [Math]::Min(8, $idx)).ToLowerInvariant() } else { '' }
        if ($prefix) {
            if (-not $script:CtByPrefix.ContainsKey($prefix)) { $script:CtByPrefix[$prefix] = @() }
            $script:CtByPrefix[$prefix] += $entry
        } else {
            $script:CtNoPrefix += $entry
        }
    }
    # mais específicos (mais longos) primeiro
    foreach ($k in @($script:CtByPrefix.Keys)) {
        $script:CtByPrefix[$k] = @($script:CtByPrefix[$k] | Sort-Object { -$_.Len })
    }
    $script:CtNoPrefix = @($script:CtNoPrefix | Sort-Object { -$_.Len })
    $script:CtLoaded = $true
    return $n
}

function Convert-CtResultLine {
    param([string]$Line)

    $trimmed = $Line.Trim()
    if ($trimmed.Length -lt 3) { return $Line }

    $indent = $Line.Substring(0, $Line.Length - $Line.TrimStart().Length)

    # 1) correspondência exata
    if ($script:CtExact.ContainsKey($trimmed)) {
        return $indent + $script:CtExact[$trimmed]
    }

    # 2) padrões, filtrados por prefixo
    $cands = @()
    if ($trimmed.Length -ge 1) {
        $pfx = $trimmed.Substring(0, [Math]::Min(8, $trimmed.Length)).ToLowerInvariant()
        for ($l = $pfx.Length; $l -ge 1; $l--) {
            $key = $pfx.Substring(0, $l)
            if ($script:CtByPrefix.ContainsKey($key)) { $cands += $script:CtByPrefix[$key] }
        }
    }
    $cands += $script:CtNoPrefix

    foreach ($c in $cands) {
        $m = $c.Regex.Match($trimmed)
        if ($m.Success) {
            $pt = $c.Pt
            for ($i = 1; $i -lt $m.Groups.Count; $i++) {
                $pt = $pt.Replace("{$($i-1)}", $m.Groups[$i].Value)
            }
            return $indent + $pt
        }
    }

    # 3) afixos markdown: tenta traduzir o miolo (## Título, - item, **Título**)
    $m3 = [regex]::Match($trimmed, '^(#{1,6}\s+|[-*]\s+|\*\*)(.+?)(\*\*)?$')
    if ($m3.Success) {
        $core = $m3.Groups[2].Value
        $ptCore = Convert-CtResultLine -Line $core
        if ($ptCore -ne $core) {
            return $indent + $m3.Groups[1].Value + $ptCore + $m3.Groups[3].Value
        }
    }

    # 4) título dentro de link markdown: [Texto](url) -> traduz só o texto,
    # preservando a URL intacta.
    $m4 = [regex]::Match($trimmed, '^\[([^\]]+)\]\(([^)]+)\)$')
    if ($m4.Success) {
        $core = $m4.Groups[1].Value
        if ($script:CtExact.ContainsKey($core)) {
            return $indent + '[' + $script:CtExact[$core] + '](' + $m4.Groups[2].Value + ')'
        }
    }

    # 5) linhas de tabela: traduz apenas células que batem exatamente com um
    # termo conhecido (Pass/Fail/Enabled/...). Dado do cliente nunca casa.
    if ($trimmed.StartsWith('|') -and $trimmed.Contains('|')) {
        $cells = $trimmed -split '\|'
        $changed = $false
        for ($i = 0; $i -lt $cells.Count; $i++) {
            $cell = $cells[$i].Trim()
            if ($cell -and $script:CtCell.ContainsKey($cell)) {
                $cells[$i] = ' ' + $script:CtCell[$cell] + ' '
                $changed = $true
            }
        }
        if ($changed) { return $indent + ($cells -join '|') }
    }

    return $Line
}

function Convert-CtResultText {
    [CmdletBinding()]
    param([string]$Text)
    if (-not $script:CtLoaded) { $null = Initialize-CtResultTranslator }
    if ([string]::IsNullOrEmpty($Text)) { return $Text }
    $lines = $Text -split "`n"
    $outLines = foreach ($ln in $lines) { Convert-CtResultLine -Line ($ln.TrimEnd("`r")) }
    return ($outLines -join "`n")
}