param(
    [Parameter(Mandatory=$true)][string]$Capture,
    [Parameter(Mandatory=$true)][string]$Pdb,
    [Parameter(Mandatory=$true)][string]$Output
)
# Offline only, after collection/timing has ended. Reads installed VS components
# and local symbols; never downloads symbols or modifies a production binary.
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'CPUReportIO.ps1')
$repo=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$allowed=[IO.Path]::GetFullPath((Join-Path $repo 'Artifacts'))+[IO.Path]::DirectorySeparatorChar
if(-not[IO.Path]::IsPathRooted($Output) -or -not([IO.Path]::GetFullPath($Output)).StartsWith($allowed,[StringComparison]::OrdinalIgnoreCase) -or (Test-Path -LiteralPath $Output)){throw 'Use a new absolute project-local profile analysis directory'}
function Read([string]$Path){Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json}
function Assert([bool]$Condition,[string]$Message){if(-not$Condition){throw $Message}}
function Hash([string]$Path){(Get-FileHash -LiteralPath $Path).Hash}
function Save($Value,[string]$Path){$Value | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath $Path -Encoding utf8}
$captureFile=Join-Path $Capture 'capture.json';$record=Read $captureFile
Assert ($record.schema -ceq 'DeepPluck-CPU-Profile-Capture-Rev1' -and $record.captureComplete -and $record.workloadCompleted -and $record.caseId -match '^[BCD]-') 'Missing genuine approved-scope capture'
Assert ((Hash $record.executable) -ieq $record.executableSha256 -and (Hash $record.metadata) -ieq $record.metadataSha256 -and
    (Hash $record.audioDiagnostic) -ieq $record.audioDiagnosticSha256 -and (Hash $record.gateG1) -ieq $record.gateG1Sha256) 'Capture input changed'
$gate=Read $record.gateG1
Assert ($gate.g1Pass -and $gate.profilingAllowed -and -not$gate.syntheticUnitFixture) 'G1 not passed'
$workload=Read (Join-Path $record.workload 'results.json')
Assert ((Hash (Join-Path $record.workload 'results.json')) -ieq $record.workloadResultSha256 -and
    $workload.completed_requested_run -and $workload.profile_wall_seconds -ge 30 -and $workload.profile_repetitions -ge 2 -and
    $workload.cases.Count -eq 1 -and $workload.cases[0].id -ceq $record.caseId) 'Incomplete profile workload'
Assert ($record.traces.Count -eq 1) 'Select/validate the actual CPU ETL; multiple traces require explicit analysis'
$trace=$record.traces[0]
Assert ((Hash $trace.path) -ieq $trace.sha256 -and (Get-Item -LiteralPath $trace.path).Length -eq $trace.bytes) 'Trace changed'
New-Item -ItemType Directory -Path $Output | Out-Null
$vs='C:/Program Files/Microsoft Visual Studio/18/Community'
$private=Join-Path $vs 'Common7/IDE/PrivateAssemblies'
$dependencies=@(
    (Join-Path $vs 'Common7/IDE/CommonExtensions/Microsoft/TestWindow/VsTest/Extensions/Microsoft.Diagnostics.NETCore.Client.dll'),
    (Join-Path $private 'Microsoft.Diagnostics.FastSerialization.dll'),
    (Join-Path $private 'Microsoft.Diagnostics.Tracing.TraceEvent.dll')
)
foreach($dependency in $dependencies){$null=[Reflection.Assembly]::LoadFrom($dependency)}
$options=[Microsoft.Diagnostics.Tracing.Etlx.TraceLogOptions]::new()
$options.LocalSymbolsOnly=$true;$options.AlwaysResolveSymbols=$false;$options.KeepAllEvents=$true;$options.ContinueOnError=$false
$options.ConversionLogName=Join-Path $Output 'conversion.log'
$etlx=Join-Path $Output 'capture.etlx'
$null=[Microsoft.Diagnostics.Tracing.Etlx.TraceLog]::CreateFromEventTraceLogFile($trace.path,$etlx,$options,[Microsoft.Diagnostics.Tracing.TraceEventDispatcherOptions]::None)
$log=[Microsoft.Diagnostics.Tracing.Etlx.TraceLog]::new($etlx)
$symbolWriter=$null;$reader=$null
$summary=[ordered]@{schema='DeepPluck-CPU-Profile-Analysis-Rev1';caseId=$record.caseId;capture=$captureFile;
    captureSha256=(Hash $captureFile);trace=$trace;profiler='Installed Visual Studio CPU Usage native sampling agent';
    symbolsResolved=$false;profilePass=$false;fullMilestonePass=$false;error=$null}
try {
    $targets=@($log.Processes | Where-Object {$_.Name -match '^DeepPluckBenchmark(?:\.exe)?$' -and $_.CommandLine.Contains($record.workload)})
    Assert ($targets.Count -eq 1) 'Cannot uniquely identify the captured benchmark process'
    $target=$targets[0]
    Assert ($target.ExitStatus -eq 0 -and ($target.EndTime-$target.StartTime).TotalSeconds -ge 30) 'Native target exit/duration failed'
    Assert ($log.EventsLost -eq 0) 'Lost ETW events: profile is incomplete'
    $summary.processId=$target.ProcessID;$summary.commandLine=$target.CommandLine;$summary.nativeExitStatus=$target.ExitStatus
    $summary.durationSeconds=($target.EndTime-$target.StartTime).TotalSeconds
    $summary.traceDurationSeconds=$log.SessionDuration.TotalSeconds
    $summary.sampleIntervalMilliseconds=$log.SampleProfileInterval.TotalMilliseconds;$summary.eventsLost=$log.EventsLost
    $cache=Join-Path $Output 'symbols';New-Item -ItemType Directory -Path $cache | Out-Null
    $symbolWriter=[IO.StreamWriter]::new((Join-Path $Output 'symbols.log'),$false)
    $reader=[Microsoft.Diagnostics.Symbols.SymbolReader]::new($symbolWriter,('srv*'+$cache+';'+(Split-Path -Parent $Pdb)),$null)
    $reader.Options=[Microsoft.Diagnostics.Symbols.SymbolReaderOptions]::CacheOnly -bor [Microsoft.Diagnostics.Symbols.SymbolReaderOptions]::NoNGenSymbolCreation
    Assert ([IO.Path]::GetFullPath($reader.SymbolCacheDirectory) -ieq [IO.Path]::GetFullPath($cache)) 'Symbol cache escaped output directory'
    $native=$reader.OpenSymbolFile($Pdb)
    Assert ($null -ne $native -and $native.PdbGuid -ne [Guid]::Empty) 'Native PDB could not be opened'
    $modules=@($log.ModuleFiles | Where-Object {[IO.Path]::GetFileName($_.FilePath) -ieq 'DeepPluckBenchmark.exe' -and $_.PdbSignature -eq $native.PdbGuid -and $_.PdbAge -eq $native.PdbAge})
    Assert ($modules.Count -eq 1) 'PDB GUID/age does not uniquely match the captured benchmark module'
    $null=$log.CodeAddresses.LookupSymbolsForModule($reader,$modules[0])
    $summary.pdb=[ordered]@{path=[IO.Path]::GetFullPath($Pdb);sha256=(Hash $Pdb);guid=$native.PdbGuid.ToString();age=$native.PdbAge;module=$modules[0].FilePath}

    # Absolute MSVC steady_clock is QPC scaled to seconds. Resolve samples to
    # explicit per-repetition loop windows, not linear interpolation of audio time.
    $windows=[Collections.Generic.List[object]]::new()
    $diagnostic=Read $record.audioDiagnostic
    for($repeat=0;$repeat -lt $workload.profile_repetitions;$repeat++) {
        $path=Join-Path $record.workload ($record.caseId+'-rep'+$repeat+'-profile-markers.json')
        $marker=Read $path
        Assert ($marker.repeat -eq $repeat -and $marker.manifest_sha256 -ceq $diagnostic.manifest_sha256 -and $marker.profile_phase_markers.Count -gt 0) 'Missing or mismatched workload markers'
        $points=@($marker.profile_phase_markers)
        for($i=0;$i -lt $points.Count;$i++) {
            $start=[double]$points[$i].steady_epoch_seconds
            $end=if($i+1 -lt $points.Count){[double]$points[$i+1].steady_epoch_seconds}else{[double]$marker.profile_loop_end_steady_epoch_seconds}
            Assert ($end -gt $start) 'Invalid profile loop phase boundary'
            $windows.Add([pscustomobject]@{repeat=$repeat;phase=$points[$i].phase;
                firstQpc=[long][math]::Round($start*$record.qpcFrequency);endQpc=[long][math]::Round($end*$record.qpcFrequency);
                firstBlock=$points[$i].first_block;firstSample=$points[$i].sample;warmup=($repeat -eq 0);samples=0;callbackSamples=0})
        }
    }
    $windows=@($windows | Sort-Object firstQpc)
    for($i=1;$i -lt $windows.Count;$i++){Assert ($windows[$i].firstQpc -ge $windows[$i-1].endQpc) 'Overlapping profile repetitions'}
    $addressCache=@{};$functionCounts=@{};$tree=@{};$stackMap=@{}
    $samples=[Collections.Generic.List[object]]::new();$stackRows=[Collections.Generic.List[object]]::new()
    $sourceRows=[Collections.Generic.List[object]]::new();$sourceErrors=[Collections.Generic.List[object]]::new()
    function Address($Code) {
        if($null -eq $Code){return $null}
        $key=[int]$Code.CodeAddressIndex
        if($addressCache.ContainsKey($key)){return $addressCache[$key]}
        $name=[string]$Code.FullMethodName;$module=[string]$Code.ModuleFilePath
        if([string]::IsNullOrWhiteSpace($name)){$name=([IO.Path]::GetFileName($module))+'!0x'+([uint64]$Code.Address).ToString('x')}
        $item=[pscustomobject]@{index=$key;function=$name;module=$module;address=('0x'+([uint64]$Code.Address).ToString('x'));source='';line=0}
        if([IO.Path]::GetFileName($module) -ieq 'DeepPluckBenchmark.exe') {
            try {
                $location=$log.CodeAddresses.GetSourceLine($reader,$Code.CodeAddressIndex)
                if($null -ne $location -and $null -ne $location.SourceFile) {
                    $item.source=$location.SourceFile.BuildTimeFilePath;$item.line=$location.LineNumber
                    if($item.source -and $item.line -gt 0){$sourceRows.Add($item)}
                }
            }catch{$sourceErrors.Add([pscustomobject]@{address=$item.address;message=$_.Exception.Message})}
        }
        $addressCache[$key]=$item
        return $item
    }
    $windowIndex=0;$targetSamples=0;$stackSamples=0;$callbackSamples=0;$unresolvedCallbackLeaves=0;$warmupCallbacks=0
    foreach($event in $target.EventsInProcess) {
        if($event -isnot [Microsoft.Diagnostics.Tracing.Parsers.Kernel.SampledProfileTraceData]){continue}
        $targetSamples++;$qpc=[long]$event.TimeStampQPC
        while($windowIndex -lt $windows.Count -and $qpc -ge $windows[$windowIndex].endQpc){$windowIndex++}
        $window=if($windowIndex -lt $windows.Count -and $qpc -ge $windows[$windowIndex].firstQpc){$windows[$windowIndex]}else{$null}
        if($null -ne $window){$window.samples++}
        $frames=[Collections.Generic.List[object]]::new()
        $leaf=Address ([Microsoft.Diagnostics.Tracing.Etlx.TraceLogExtensions]::IntructionPointerCodeAddress($event))
        if($null -ne $leaf){$frames.Add($leaf)}
        $stack=[Microsoft.Diagnostics.Tracing.Etlx.TraceLogExtensions]::CallStack($event)
        if($null -ne $stack){$stackSamples++}
        $depth=0
        while($null -ne $stack) {
            Assert ($depth++ -lt 512) 'Invalid/cyclic stack'
            $frame=Address $stack.CodeAddress
            if($null -ne $frame -and ($frames.Count -eq 0 -or $frames[$frames.Count-1].address -cne $frame.address)){$frames.Add($frame)}
            $stack=$stack.Caller
        }
        $anchor=-1
        for($i=0;$i -lt $frames.Count;$i++) {
            if($frames[$i].function -match 'DeepPluckAudioProcessor::processBlock(?:Bypassed)?\b'){$anchor=$i;break}
        }
        $inCallback=$anchor -ge 0 -and $null -ne $window
        $accepted=$inCallback -and -not$window.warmup
        if($inCallback){$window.callbackSamples++;if($window.warmup){$warmupCallbacks++}}
        $stackKey=($frames.index -join ',')
        if(-not$stackMap.ContainsKey($stackKey)){
            $stackId=$stackMap.Count;$stackMap[$stackKey]=$stackId
            $stackRows.Add([pscustomobject]@{stackId=$stackId;leafToRoot=@($frames)})
        }
        $samples.Add([pscustomobject]@{qpc=$qpc;threadId=$event.ThreadID;repeat=$(if($window){$window.repeat}else{-1});
            phase=$(if($window){$window.phase}else{'setup-or-between-loops'});warmup=$(if($window){$window.warmup}else{$false});
            acceptedCallback=$accepted;stackId=$stackMap[$stackKey]})
        if(-not$accepted){continue}
        $callbackSamples++
        $seen=@{};$parent=-1
        for($i=$anchor;$i -ge 0;$i--) {
            $frame=$frames[$i];$name=$frame.function
            if(-not$functionCounts.ContainsKey($name)){$functionCounts[$name]=[pscustomobject]@{function=$name;module=$frame.module;selfSamples=0;totalSamples=0;selfPercent=0.0;totalPercent=0.0}}
            if(-not$seen.ContainsKey($name)){$functionCounts[$name].totalSamples++;$seen[$name]=$true}
            if($i -eq 0){$functionCounts[$name].selfSamples++;if($name -match '!0x[0-9a-f]+$'){$unresolvedCallbackLeaves++}}
            $nodeKey="$parent/$name"
            if(-not$tree.ContainsKey($nodeKey)){$tree[$nodeKey]=[pscustomobject]@{nodeId=$tree.Count;parentId=$parent;function=$name;totalSamples=0;selfSamples=0;totalPercent=0.0}}
            $tree[$nodeKey].totalSamples++;if($i -eq 0){$tree[$nodeKey].selfSamples++}
            $parent=$tree[$nodeKey].nodeId
        }
    }
    Assert ($targetSamples -gt 0 -and $stackSamples -gt 0 -and $callbackSamples -gt 0) 'No symbol-resolved production callback samples'
    foreach($row in $functionCounts.Values){$row.selfPercent=100.0*$row.selfSamples/$callbackSamples;$row.totalPercent=100.0*$row.totalSamples/$callbackSamples}
    foreach($row in $tree.Values){$row.totalPercent=100.0*$row.totalSamples/$callbackSamples}
    $flat=@($functionCounts.Values | Sort-Object selfSamples -Descending)
    $nodes=@($tree.Values | Sort-Object nodeId)
    $hot=[Collections.Generic.List[object]]::new();$parent=-1
    while($true){$next=@($nodes | Where-Object {$_.parentId -eq $parent} | Sort-Object totalSamples -Descending | Select-Object -First 1);if($next.Count -eq 0){break};$hot.Add($next[0]);$parent=$next[0].nodeId}
    Export-CPUCsv $flat (Join-Path $Output 'functions.csv')
    Export-CPUCsv $nodes (Join-Path $Output 'call-tree.csv')
    Export-CPUCsv @($hot) (Join-Path $Output 'hot-path.csv')
    Export-CPUCsv @($samples) (Join-Path $Output 'samples.csv')
    Export-CPUCsv $windows (Join-Path $Output 'phase-windows.csv')
    Save @($stackRows) (Join-Path $Output 'stacks.json')
    Save @($sourceRows) (Join-Path $Output 'source-addresses.json')
    Save @($sourceErrors) (Join-Path $Output 'source-errors.json')
    Assert (@($sourceRows | Where-Object {$_.source -match '[\\/]Source[\\/]' -and $_.line -gt 0}).Count -gt 0) 'No actual production source line resolved'
    $summary.targetSamples=$targetSamples;$summary.samplesWithStacks=$stackSamples;$summary.callbackSamples=$callbackSamples
    $summary.warmupCallbackSamplesExcluded=$warmupCallbacks;$summary.unresolvedCallbackLeafSamples=$unresolvedCallbackLeaves
    $summary.sourceLocations=@($sourceRows | Where-Object {$_.source -match '[\\/]Source[\\/]'} | Select-Object source,line -Unique)
    $summary.callTree=[ordered]@{path=(Join-Path $Output 'call-tree.csv');sha256=(Hash (Join-Path $Output 'call-tree.csv'))}
    $summary.hotPath=[ordered]@{path=(Join-Path $Output 'hot-path.csv');sha256=(Hash (Join-Path $Output 'hot-path.csv'))}
    $summary.dependencies=@($dependencies | ForEach-Object {[ordered]@{path=$_;sha256=(Hash $_)}})
    $summary.units='CPU samples and percentage of accepted callback samples, NOT milliseconds. Setup/between-loop and first-loop warmup excluded. Inclusive counts are not additive.'
    $summary.symbolsResolved=$true;$summary.profilePass=$true
    Write-Host "PASS: $($record.caseId), $callbackSamples actual callback samples, source/PDB identity verified"
}catch{$summary.error=$_.Exception.Message;throw}
finally{
    $summary.completedUtc=[DateTime]::UtcNow.ToString('o');Save $summary (Join-Path $Output 'profile.json')
    if($null -ne $reader){$reader.Dispose()};if($null -ne $symbolWriter){$symbolWriter.Dispose()};$log.Dispose()
}
