#!/usr/bin/env pwsh
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
#
# Invoke-PesterTests.ps1
#
# Purpose: Pester test runner that writes summary and failure details to logs/
# Author: HVE Core Team

#Requires -Version 7.0

<#
.SYNOPSIS
    Runs Pester tests and writes structured output to the logs/ directory.

.DESCRIPTION
    Wraps Invoke-Pester with the repository's Pester configuration and writes
    logs/pester-summary.json (pass/fail counts, duration) and
    logs/pester-failures.json (failure details with error messages and stack traces).

.PARAMETER TestPath
    One or more paths to test files or directories. Defaults to the scripts/tests/ directory.

.PARAMETER CI
    Enables CI mode: NUnit XML output, exit-on-failure, and GitHub Actions log format.

.PARAMETER CodeCoverage
    Enables JaCoCo code coverage reporting to logs/coverage.xml.

.PARAMETER Tag
    Run only tests whose Describe/Context/It blocks carry one of the supplied tags.
    `-IncludeTag` is accepted as an alias.

.PARAMETER ExcludeTag
    Exclude tests whose blocks carry any of the supplied tags. When omitted, defaults
    to @('Integration','Slow') to preserve historical behavior. Passing this parameter
    (including `-ExcludeTag @()`) replaces the default rather than appending to it.

.EXAMPLE
    ./scripts/tests/Invoke-PesterTests.ps1

.EXAMPLE
    ./scripts/tests/Invoke-PesterTests.ps1 -TestPath "scripts/tests/linting/"

.EXAMPLE
    ./scripts/tests/Invoke-PesterTests.ps1 -Tag Unit

.EXAMPLE
    ./scripts/tests/Invoke-PesterTests.ps1 -ExcludeTag Slow

.EXAMPLE
    ./scripts/tests/Invoke-PesterTests.ps1 -CI -CodeCoverage
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [string[]]$TestPath,

    [Parameter(Mandatory = $false)]
    [switch]$CI,

    [Parameter(Mandatory = $false)]
    [switch]$CodeCoverage,

    [Parameter(Mandatory = $false)]
    [Alias('IncludeTag')]
    [string[]]$Tag,

    [Parameter(Mandatory = $false)]
    [string[]]$ExcludeTag
)

$ErrorActionPreference = 'Stop'

$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
    $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
}
$logsDir = Join-Path $repoRoot 'logs'
$configScript = Join-Path $PSScriptRoot 'pester.config.ps1'
$summaryPath = Join-Path $logsDir 'pester-summary.json'
$failuresPath = Join-Path $logsDir 'pester-failures.json'

if (-not (Test-Path $logsDir)) {
    New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
}

# Pre-write placeholder outputs so tests that assert these files exist during
# the run (activation harness) see them even before Invoke-Pester completes.
'{}' | Out-File -FilePath $summaryPath -Encoding utf8
'[]' | Out-File -FilePath $failuresPath -Encoding utf8

# Pin Pester to the canonical version from scripts/security/ps-module-versions.json.
# This is the single enforcement point: test files use plain `#Requires -Modules Pester`
# and rely on the runner to import the correct version before discovery/execution.
$pinConfigPath = Join-Path $repoRoot 'scripts/security/ps-module-versions.json'
if (-not (Test-Path $pinConfigPath)) {
    throw "PowerShell module pin config not found: $pinConfigPath"
}
$pinConfig = Get-Content -Path $pinConfigPath -Raw | ConvertFrom-Json
$pesterVersion = $pinConfig.modules.Pester.version
if (-not $pesterVersion) {
    throw "Pester version not defined in $pinConfigPath"
}
Import-Module -Name Pester -RequiredVersion $pesterVersion -ErrorAction Stop

# Build config arguments
$configArgs = @{}
if ($CI) {
    $configArgs['CI'] = $true
}
if ($CodeCoverage) {
    $configArgs['CodeCoverage'] = $true
}
if ($TestPath) {
    $resolvedPaths = @($TestPath | ForEach-Object {
        $p = if ([System.IO.Path]::IsPathRooted($_)) { $_ } else { Join-Path $repoRoot $_ }
        if (-not (Test-Path $p)) {
            Write-Warning "Test path not found: $_"
        }
        $p
    })
    if ($resolvedPaths.Count -gt 0) {
        $configArgs['TestPath'] = $resolvedPaths
    }
}
if ($Tag) {
    $configArgs['Tag'] = $Tag
}
if ($PSBoundParameters.ContainsKey('ExcludeTag')) {
    $configArgs['ExcludeTag'] = $ExcludeTag
}

$configuration = & $configScript @configArgs

# Ensure PassThru and file output are enabled regardless of CI flag
$configuration.Run.PassThru = $true

Write-Host "🧪 Running Pester tests..." -ForegroundColor Cyan
if ($TestPath) {
    Write-Host "   Test paths: $($TestPath -join ', ')" -ForegroundColor Cyan
}
if ($Tag) {
    Write-Host "   Tag filter: $($Tag -join ', ')" -ForegroundColor Cyan
}
if ($PSBoundParameters.ContainsKey('ExcludeTag')) {
    $excludeDisplay = if ($ExcludeTag -and $ExcludeTag.Count -gt 0) { $ExcludeTag -join ', ' } else { '(none)' }
    Write-Host "   ExcludeTag override: $excludeDisplay" -ForegroundColor Cyan
}

$result = Invoke-Pester -Configuration $configuration

# Build summary
$summary = [ordered]@{
    Timestamp    = (Get-Date -Format 'o')
    Result       = $result.Result
    TotalCount   = $result.TotalCount
    PassedCount  = $result.PassedCount
    FailedCount  = $result.FailedCount
    SkippedCount = $result.SkippedCount
    Duration     = $result.Duration.ToString()
}

if ($CodeCoverage -and $result.CodeCoverage) {
    $summary['CoveragePercent'] = [math]::Round($result.CodeCoverage.CoveragePercent, 2)
    $coverageTarget = $configuration.CodeCoverage.CoveragePercentTarget.Value
    if ($null -ne $coverageTarget) {
        $summary['CoverageTarget'] = [math]::Round([double]$coverageTarget, 2)
    }
}

$summary | ConvertTo-Json -Depth 3 | Out-File -FilePath $summaryPath -Encoding utf8

# Build failures list
$failures = @()
foreach ($test in $result.Tests) {
    if ($test.Result -eq 'Failed') {
        $failures += [ordered]@{
            Name         = $test.ExpandedName
            Path         = $test.ScriptBlock.File
            ErrorMessage = ($test.ErrorRecord | ForEach-Object { $_.Exception.Message }) -join "`n"
            StackTrace   = ($test.ErrorRecord | ForEach-Object { $_.ScriptStackTrace }) -join "`n"
        }
    }
}

# Recursively collect failures from containers when Tests are nested
function Get-FailedTests {
    param([object[]]$Blocks)
    $collected = @()
    foreach ($block in $Blocks) {
        if ($block.Tests) {
            foreach ($test in $block.Tests) {
                if ($test.Result -eq 'Failed') {
                    $collected += [ordered]@{
                        Name         = $test.ExpandedName
                        Path         = if ($test.ScriptBlock.File) { $test.ScriptBlock.File } else { '' }
                        ErrorMessage = ($test.ErrorRecord | ForEach-Object { $_.Exception.Message }) -join "`n"
                        StackTrace   = ($test.ErrorRecord | ForEach-Object { $_.ScriptStackTrace }) -join "`n"
                    }
                }
            }
        }
        if ($block.Blocks) {
            $collected += Get-FailedTests -Blocks $block.Blocks
        }
    }
    return $collected
}

# If top-level Tests didn't capture failures, walk the container tree
if ($failures.Count -eq 0 -and $result.FailedCount -gt 0) {
    $failures = @(Get-FailedTests -Blocks $result.Containers)
}

$failures | ConvertTo-Json -Depth 5 | Out-File -FilePath $failuresPath -Encoding utf8

# Report
if ($result.FailedCount -gt 0) {
    Write-Host "`n❌ $($result.FailedCount) test(s) failed. See logs/pester-summary.json and logs/pester-failures.json" -ForegroundColor Red
    exit 1
}
else {
    Write-Host "`n✅ All $($result.PassedCount) tests passed." -ForegroundColor Green
    exit 0
}
