Public/New-TechCareReport.ps1

function New-TechCareReport {
    <#
    .SYNOPSIS
        Genera el informe de la ejecucion en HTML, JSON y texto.
    .DESCRIPTION
        Construye un informe autocontenido (un solo fichero HTML, sin recursos
        externos ni peticiones de red) con el estado global, los hallazgos, las
        acciones realizadas, el espacio liberado y las recomendaciones manuales.
        Junto al HTML escribe un JSON con el mismo contenido para herramientas y
        un log de texto para pegar en el ticket.

        Es el unico cmdlet del modulo que funciona en cualquier sistema operativo:
        solo da forma a lo que ya esta en memoria, no consulta el equipo. Por eso
        la suite de pruebas puede verificarlo en Linux.
    .PARAMETER ReportPath
        Carpeta destino. Por defecto, una subcarpeta con el identificador de la
        ejecucion dentro de la carpeta temporal del usuario.
    .PARAMETER TechnicianName
        Nombre del tecnico, para la cabecera del informe.
    .PARAMETER CompanyName
        Nombre de la empresa, para la cabecera del informe.
    .PARAMETER ContactInfo
        Telefono o correo de contacto, para la cabecera del informe.
    .EXAMPLE
        New-TechCareReport

        Escribe el informe en la carpeta temporal y devuelve las tres rutas.
    .EXAMPLE
        New-TechCareReport -ReportPath C:\Reportes -TechnicianName 'Eduard Arbona'

        Informe con cabecera personalizada en una carpeta concreta.
    .EXAMPLE
        New-TechCareReport -ReportPath C:\Reportes -WhatIf

        Dice que ficheros escribiria sin crear la carpeta ni los ficheros.
    .OUTPUTS
        PSCustomObject
    .LINK
        https://github.com/earbona23/TechCare
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    [OutputType([pscustomobject])]
    param(
        [ValidateNotNullOrEmpty()]
        [string]$ReportPath,

        [AllowEmptyString()]
        [string]$TechnicianName = '',

        [AllowEmptyString()]
        [string]$CompanyName = '',

        [AllowEmptyString()]
        [string]$ContactInfo = ''
    )

    if (-not $ReportPath) {
        if ($script:State.ReportPath) {
            # Invoke-TechCare ya fijo la carpeta de la pasada: el informe debe caer
            # junto al log incremental, no en otra carpeta distinta.
            $ReportPath = $script:State.ReportPath
        } else {
            # $env:TEMP no existe en Linux ni en macOS; GetTempPath si, en los tres.
            $ReportPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('TechCare\' + $script:State.RunId)
        }
    }

    $script:State.Branding.TechnicianName = $TechnicianName
    $script:State.Branding.CompanyName    = $CompanyName
    $script:State.Branding.ContactInfo    = $ContactInfo

    $computer = $env:COMPUTERNAME
    if ([string]::IsNullOrWhiteSpace($computer)) { $computer = [System.Environment]::MachineName }

    $results = @($script:State.Results.ToArray())
    $overall = 'Gray'
    if ($results.Count -gt 0) {
        $overall = 'Green'
        foreach ($r in $results) {
            if ($r.Status -eq 'Red') { $overall = 'Red'; break }
            if ($r.Status -eq 'Yellow') { $overall = 'Yellow' }
        }
    }

    $totalFreed = [int64]0
    foreach ($r in $results) { $totalFreed += $r.SpaceFreedBytes }

    $htmlPath = Join-Path -Path $ReportPath -ChildPath 'TechCare-Report.html'
    $jsonPath = Join-Path -Path $ReportPath -ChildPath 'TechCare-Report.json'
    $logPath  = Join-Path -Path $ReportPath -ChildPath 'TechCare-Log.txt'

    $label = @{ Green = 'Correcto'; Yellow = 'Atencion'; Red = 'Critico'; Gray = 'Sin datos' }
    $color = @{ Green = '#1a7f37'; Yellow = '#9a6700'; Red = '#b42318'; Gray = '#57606a' }

    $mode = 'Vista previa'
    if ($script:State.Apply) { $mode = 'Aplicar' }

    $sb = New-Object -TypeName System.Text.StringBuilder
    [void]$sb.AppendLine('<!DOCTYPE html><html lang="es"><head><meta charset="utf-8">')
    [void]$sb.AppendLine('<meta name="viewport" content="width=device-width,initial-scale=1">')
    [void]$sb.AppendLine('<title>TechCare - Informe</title><style>')
    [void]$sb.AppendLine('body{font-family:Segoe UI,Arial,sans-serif;margin:0;background:#f6f8fa;color:#1f2328}')
    [void]$sb.AppendLine('.wrap{max-width:960px;margin:0 auto;padding:24px}')
    [void]$sb.AppendLine('.card{background:#fff;border:1px solid #d0d7de;border-radius:8px;padding:20px;margin:16px 0}')
    [void]$sb.AppendLine('h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:0 0 10px}')
    [void]$sb.AppendLine('.banner{border-radius:8px;padding:16px;color:#fff;font-weight:600;font-size:16px}')
    [void]$sb.AppendLine('ul{margin:6px 0 0 18px;padding:0}li{margin:2px 0;font-size:14px}')
    [void]$sb.AppendLine('.muted{color:#57606a;font-size:13px}')
    [void]$sb.AppendLine('.tag{display:inline-block;font-size:12px;padding:2px 8px;border-radius:10px;color:#fff;font-weight:600}')
    [void]$sb.AppendLine('@media print{body{background:#fff}.card{break-inside:avoid}}')
    [void]$sb.AppendLine('</style></head><body><div class="wrap">')

    [void]$sb.AppendLine('<div class="card"><h1>TechCare - Informe de mantenimiento</h1>')
    [void]$sb.AppendLine(('<p class="muted">Ejecucion {0} | {1} | Equipo {2} | Preset {3} | Modo: {4}</p>' -f
        (ConvertTo-TechCareHtml -Text $script:State.RunId),
        (ConvertTo-TechCareHtml -Text ((Get-Date).ToString('yyyy-MM-dd HH:mm'))),
        (ConvertTo-TechCareHtml -Text $computer),
        (ConvertTo-TechCareHtml -Text $script:State.Preset),
        (ConvertTo-TechCareHtml -Text $mode)))

    if ($TechnicianName -or $CompanyName -or $ContactInfo) {
        [void]$sb.AppendLine('<p class="muted">')
        if ($TechnicianName) { [void]$sb.AppendLine('Tecnico: ' + (ConvertTo-TechCareHtml -Text $TechnicianName) + '<br>') }
        if ($CompanyName)    { [void]$sb.AppendLine('Empresa: ' + (ConvertTo-TechCareHtml -Text $CompanyName) + '<br>') }
        if ($ContactInfo)    { [void]$sb.AppendLine('Contacto: ' + (ConvertTo-TechCareHtml -Text $ContactInfo)) }
        [void]$sb.AppendLine('</p>')
    }
    [void]$sb.AppendLine('</div>')

    [void]$sb.AppendLine(('<div class="banner" style="background:{0}">Estado general: {1}</div>' -f $color[$overall], $label[$overall]))

    if ($totalFreed -gt 0) {
        [void]$sb.AppendLine(('<div class="card"><h2>Espacio liberado</h2><p>{0}</p></div>' -f
            (ConvertTo-TechCareHtml -Text (Format-TechCareSize -Bytes $totalFreed))))
    }

    foreach ($r in $results) {
        [void]$sb.AppendLine('<div class="card">')
        [void]$sb.AppendLine(('<h2>{0} <span class="tag" style="background:{1}">{2}</span></h2>' -f
            (ConvertTo-TechCareHtml -Text $r.Module), $color[$r.Status], $label[$r.Status]))
        foreach ($section in @(
            @{ Title = 'Hallazgos';                 Items = $r.Findings },
            @{ Title = 'Acciones realizadas';       Items = $r.Actions },
            @{ Title = 'Recomendaciones manuales';  Items = $r.Recommendations }
        )) {
            $items = @($section.Items)
            if ($items.Count -eq 0) { continue }
            [void]$sb.AppendLine('<strong>' + $section.Title + '</strong><ul>')
            foreach ($i in $items) { [void]$sb.AppendLine('<li>' + (ConvertTo-TechCareHtml -Text $i) + '</li>') }
            [void]$sb.AppendLine('</ul>')
        }
        [void]$sb.AppendLine('</div>')
    }

    [void]$sb.AppendLine(('<div class="card muted">Generado por TechCare {0}. Sin telemetria: el informe no sale de este equipo.</div>' -f
        (ConvertTo-TechCareHtml -Text $script:ModuleVersion)))
    [void]$sb.AppendLine('</div></body></html>')

    $written = $false
    # Un solo gate para los tres ficheros y la carpeta: son una sola accion
    # logica, y asi -WhatIf no crea la carpeta que dice que no va a crear.
    if ($PSCmdlet.ShouldProcess($ReportPath, 'Escribir informe HTML, JSON y log de texto')) {
        if (-not (Test-Path -LiteralPath $ReportPath)) {
            [void](New-Item -ItemType Directory -Path $ReportPath -Force)
        }

        # Se escribe con UTF8Encoding($false) en vez de Set-Content -Encoding UTF8
        # porque ese parametro NO significa lo mismo en las dos ediciones:
        # Windows PowerShell 5.1 antepone BOM y PowerShell 7 no. Un JSON con BOM
        # lo aceptan ConvertFrom-Json y Python, pero lo rechazan jq y varios
        # analizadores estrictos, asi que el mismo informe seria valido o no
        # segun la consola desde la que se genero.
        $sinBom = New-Object -TypeName System.Text.UTF8Encoding -ArgumentList $false
        [System.IO.File]::WriteAllText($htmlPath, $sb.ToString(), $sinBom)

        $payload = [pscustomobject]@{
            schemaVersion   = 1
            runId           = $script:State.RunId
            moduleVersion   = $script:ModuleVersion
            startedUtc      = $script:State.StartedUtc.ToString('o')
            generatedUtc    = (Get-Date).ToUniversalTime().ToString('o')
            computer        = $computer
            preset          = $script:State.Preset
            apply           = $script:State.Apply
            overall         = $overall
            spaceFreedBytes = $totalFreed
            results         = $results
        }
        [System.IO.File]::WriteAllText($jsonPath, ($payload | ConvertTo-Json -Depth 6), $sinBom)

        $lines = foreach ($l in $script:State.Log) {
            $text = '{0} [{1}] [{2}] {3}' -f $l.TimeUtc, $l.Level, $l.Module, $l.Message
            if ($l.Error) { $text = $text + ' :: ' + $l.Error }
            $text
        }
        # Sin entradas el fichero queda vacio, no con un salto de linea suelto:
        # 0 bytes dice "no hubo log", 1 byte parece un fichero truncado.
        $textoLog = ''
        if (@($lines).Count -gt 0) {
            $textoLog = (@($lines) -join [System.Environment]::NewLine) + [System.Environment]::NewLine
        }
        [System.IO.File]::WriteAllText($logPath, $textoLog, $sinBom)
        $written = $true
    }

    return [pscustomobject]@{
        HtmlPath = $htmlPath
        JsonPath = $jsonPath
        LogPath  = $logPath
        Overall  = $overall
        Written  = $written
    }
}