#!/usr/bin/env pwsh
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
#Requires -Version 7.0

<#
.SYNOPSIS
    Regenerate evals/agent-behavior/eval.yaml from per-agent stimulus partials.

.DESCRIPTION
    Concatenates committed per-agent partials (one stimulus list per agent slug)
    into the agent-behavior suite spec. Partials are discovered under
    `<RepoRoot>/evals/agent-behavior/stimuli/*.yml` and rendered in alphabetical
    order by file name. The agent slug is taken from the partial's base name
    and injected as `tags.agent: <slug>` on every emitted stimulus, so partial
    authors never duplicate the tag.

    Top-level keys (everything except `stimuli:`) from the existing output file
    are preserved verbatim. The single-line banner
    `# Generated by Build-AgentBehaviorSpec.ps1 - do not edit by hand.` is
    re-prepended on every run and de-duplicated, so re-running on the script's
    own output is idempotent.

    With -WhatIf, the script renders in-memory and exits 0 when the on-disk
    output already matches; otherwise it writes a line-based diff to
    `<RepoRoot>/logs/agent-behavior-spec-drift.diff` and exits 1.

.PARAMETER RepoRoot
    Repository root. Defaults to `git rev-parse --show-toplevel`.

.PARAMETER PartialsDir
    Directory containing `<slug>.yml` partials. Defaults to
    `<RepoRoot>/evals/agent-behavior/stimuli`.

.PARAMETER OutputPath
    Output spec path. Defaults to `<RepoRoot>/evals/agent-behavior/eval.yaml`.

.PARAMETER DriftDiffPath
    Path to write the line-based diff under -WhatIf. Defaults to
    `<RepoRoot>/logs/agent-behavior-spec-drift.diff`.

.PARAMETER Force
    Overwrite the output regardless of existing content. Without -Force an
    unchanged file is left untouched (no-op), and a changed file triggers an
    error so accidental clobbering of unrelated edits is surfaced.

.EXAMPLE
    pwsh scripts/evals/Build-AgentBehaviorSpec.ps1

.EXAMPLE
    pwsh scripts/evals/Build-AgentBehaviorSpec.ps1 -WhatIf

.NOTES
    Mirrors the generate-and-commit drift-check pattern used by
    `scripts/evals/New-AgentSurfaceSignatures.ps1`.
#>
[CmdletBinding(SupportsShouldProcess)]
[OutputType([string])]
param(
    [string]$RepoRoot,

    [string]$PartialsDir,

    [string]$OutputPath,

    [string]$DriftDiffPath,

    [switch]$Force
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

#region Constants

$script:GeneratorBanner = '# Generated by Build-AgentBehaviorSpec.ps1 - do not edit by hand.'

#endregion Constants

#region Functions

function Resolve-RepoRoot {
    [CmdletBinding()]
    [OutputType([string])]
    param([string]$Override)

    if ($Override) {
        return (Resolve-Path -LiteralPath $Override).Path
    }

    try {
        $root = (& git rev-parse --show-toplevel 2>$null).Trim()
        if ($LASTEXITCODE -eq 0 -and $root) { return $root }
    } catch {
        Write-Verbose "git rev-parse failed: $($_.Exception.Message)"
    }

    return (Get-Location).Path
}

function Import-YamlModule {
    [CmdletBinding()]
    param()

    if (Get-Module -Name 'powershell-yaml') { return }
    if (-not (Get-Module -ListAvailable -Name 'powershell-yaml')) {
        throw "Required module 'powershell-yaml' is not installed. Run 'Install-Module powershell-yaml -Scope CurrentUser' before invoking this script."
    }
    Import-Module powershell-yaml -ErrorAction Stop | Out-Null
}

function Get-PartialFiles {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo[]])]
    param([Parameter(Mandatory)] [string]$PartialsDir)

    if (-not (Test-Path -LiteralPath $PartialsDir)) {
        return @()
    }
    return @(Get-ChildItem -Path $PartialsDir -Filter '*.yml' -File | Sort-Object -Property Name)
}

function Read-PartialStimuli {
    [CmdletBinding()]
    [OutputType([System.Collections.IList])]
    param(
        [Parameter(Mandatory)] [string]$Path,
        [Parameter(Mandatory)] [string]$Slug
    )

    $raw = [System.IO.File]::ReadAllText($Path)
    try {
        $parsed = ConvertFrom-Yaml -Yaml $raw -Ordered
    } catch {
        throw "Failed to parse partial '$Path' as YAML: $($_.Exception.Message)"
    }

    if ($null -eq $parsed) {
        return @()
    }

    if ($parsed -isnot [System.Collections.IDictionary]) {
        throw "Partial '$Path' must be a YAML mapping with a top-level 'stimuli' key."
    }

    if (-not $parsed.Contains('stimuli')) {
        return @()
    }

    $stimuli = $parsed['stimuli']
    if ($null -eq $stimuli) {
        return @()
    }
    if ($stimuli -isnot [System.Collections.IList]) {
        throw "Partial '$Path' has a 'stimuli' key that is not a list."
    }

    $injected = [System.Collections.Generic.List[object]]::new()
    foreach ($item in $stimuli) {
        if ($item -isnot [System.Collections.IDictionary]) {
            throw "Partial '$Path' contains a stimulus entry that is not a mapping."
        }
        if (-not $item.Contains('name') -or [string]::IsNullOrWhiteSpace([string]$item['name'])) {
            throw "Partial '$Path' contains a stimulus missing a non-empty 'name' field."
        }
        if (-not $item.Contains('prompt') -or [string]::IsNullOrWhiteSpace([string]$item['prompt'])) {
            throw "Partial '$Path' stimulus '$($item['name'])' is missing a non-empty 'prompt' field."
        }

        $tags = if ($item.Contains('tags')) { $item['tags'] } else { $null }
        if ($null -eq $tags) {
            $tags = [ordered]@{}
            $item['tags'] = $tags
        } elseif ($tags -isnot [System.Collections.IDictionary]) {
            throw "Partial '$Path' stimulus '$($item['name'])' has a non-mapping 'tags' value."
        }

        if ($tags.Contains('agent')) {
            $existing = [string]$tags['agent']
            if ($existing -ne $Slug) {
                throw "Partial '$Path' stimulus '$($item['name'])' declares tags.agent='$existing' but file slug is '$Slug'. Remove the agent tag from the partial; the generator injects it from the file name."
            }
        } else {
            $tags['agent'] = $Slug
        }

        $injected.Add($item)
    }
    return , $injected
}

function Split-ExistingPrelude {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param([string]$ExistingText)

    if (-not $ExistingText) {
        return @{ Prelude = ''; HadStimuli = $false }
    }

    $lines = $ExistingText -split "(?<=`n)"
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ($lines[$i] -match '^stimuli\s*:') {
            $preludeLines = if ($i -gt 0) { $lines[0..($i - 1)] } else { @() }
            return @{ Prelude = ($preludeLines -join ''); HadStimuli = $true }
        }
    }

    $trailingNewline = if ($ExistingText.EndsWith("`n")) { '' } else { "`n" }
    return @{ Prelude = ($ExistingText + $trailingNewline); HadStimuli = $false }
}

function Remove-LeadingBanner {
    [CmdletBinding()]
    [OutputType([string])]
    param([string]$Prelude)

    if (-not $Prelude) { return '' }
    $lines = $Prelude -split "(?<=`n)"
    $skip = 0
    while ($skip -lt $lines.Count -and $lines[$skip].TrimEnd("`r", "`n").StartsWith('# Generated by Build-AgentBehaviorSpec.ps1')) {
        $skip++
    }
    if ($skip -eq 0) { return $Prelude }
    if ($skip -ge $lines.Count) { return '' }
    return ($lines[$skip..($lines.Count - 1)] -join '')
}

function Format-StimuliBlock {
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter()] [System.Collections.IList]$Stimuli)

    if (-not $Stimuli -or $Stimuli.Count -eq 0) {
        return "stimuli: []`n"
    }

    $wrapper = [ordered]@{ stimuli = $Stimuli }
    $rendered = ConvertTo-Yaml -Data $wrapper
    if (-not $rendered.EndsWith("`n")) { $rendered += "`n" }
    return $rendered
}

function Get-RenderedSpec {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()] [string]$ExistingText,
        [Parameter()] [System.Collections.IList]$Stimuli
    )

    $split = Split-ExistingPrelude -ExistingText $ExistingText
    $prelude = Remove-LeadingBanner -Prelude $split.Prelude

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.Append($script:GeneratorBanner)
    [void]$sb.Append("`n")
    if ($prelude) {
        [void]$sb.Append($prelude)
        if (-not $prelude.EndsWith("`n")) { [void]$sb.Append("`n") }
    }
    [void]$sb.Append((Format-StimuliBlock -Stimuli $Stimuli))
    return $sb.ToString()
}

function Get-LineDiff {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string]$Expected,
        [Parameter(Mandatory)] [string]$Actual,
        [Parameter(Mandatory)] [string]$Path
    )

    $expectedLines = $Expected -split "`r?`n"
    $actualLines = $Actual -split "`r?`n"
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("--- expected $Path")
    [void]$sb.AppendLine("+++ actual   $Path")

    $diff = Compare-Object -ReferenceObject $expectedLines -DifferenceObject $actualLines
    foreach ($entry in $diff) {
        $prefix = if ($entry.SideIndicator -eq '<=') { '-' } else { '+' }
        [void]$sb.AppendLine("$prefix$($entry.InputObject)")
    }
    return $sb.ToString()
}

#endregion Functions

#region Main Execution

$resolvedRoot = Resolve-RepoRoot -Override $RepoRoot
if (-not $PartialsDir) {
    $PartialsDir = Join-Path $resolvedRoot 'evals/agent-behavior/stimuli'
}
if (-not $OutputPath) {
    $OutputPath = Join-Path $resolvedRoot 'evals/agent-behavior/eval.yaml'
}
if (-not $DriftDiffPath) {
    $DriftDiffPath = Join-Path $resolvedRoot 'logs/agent-behavior-spec-drift.diff'
}

Import-YamlModule

$partials = Get-PartialFiles -PartialsDir $PartialsDir
$allStimuli = [System.Collections.Generic.List[object]]::new()
foreach ($partial in $partials) {
    $slug = $partial.BaseName
    foreach ($stimulus in (Read-PartialStimuli -Path $partial.FullName -Slug $slug)) {
        $allStimuli.Add($stimulus)
    }
}

$existingText = if (Test-Path -LiteralPath $OutputPath) {
    [System.IO.File]::ReadAllText($OutputPath) -replace "`r`n", "`n"
} else {
    ''
}

$rendered = Get-RenderedSpec -ExistingText $existingText -Stimuli $allStimuli
# ConvertTo-Yaml emits CRLF on Windows; normalize to LF so on-disk content
# stays platform-stable and drift comparisons are byte-accurate.
$rendered = $rendered -replace "`r`n", "`n"

if ($WhatIfPreference) {
    if ($existingText -eq $rendered) {
        Write-Host "no drift: $OutputPath" -ForegroundColor Green
        exit 0
    }
    $diffDir = Split-Path -Parent $DriftDiffPath
    if ($diffDir -and -not (Test-Path -LiteralPath $diffDir)) {
        # -WhatIf:$false bypasses inherited WhatIfPreference so the diff dir is
        # always materialized during drift detection runs.
        New-Item -ItemType Directory -Path $diffDir -Force -WhatIf:$false | Out-Null
    }
    $diffText = Get-LineDiff -Expected $rendered -Actual $existingText -Path $OutputPath
    [System.IO.File]::WriteAllText($DriftDiffPath, $diffText)
    Write-Host "drift detected; diff written to $DriftDiffPath" -ForegroundColor Yellow
    exit 1
}

if ((Test-Path -LiteralPath $OutputPath) -and -not $Force) {
    if ($existingText -eq $rendered) {
        Write-Host "skipped (no changes): $OutputPath" -ForegroundColor Gray
        return $OutputPath
    }
    throw "Output file already exists and differs from rendered content. Re-run with -Force to overwrite: $OutputPath"
}

$outputDir = Split-Path -Parent $OutputPath
if ($outputDir -and -not (Test-Path -LiteralPath $outputDir)) {
    New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
[System.IO.File]::WriteAllText($OutputPath, $rendered)
Write-Host "wrote: $OutputPath" -ForegroundColor Green
return $OutputPath

#endregion Main Execution
