# BuildCommon.psm1
#
# Shared helpers for the NextNote FFmpeg build scripts. Contains the single
# implementation of the pinned, fail-closed downloader plus lock parsing and
# MSYS2 invocation utilities. Scripts import this module; none of them
# duplicate this logic (PROJECT_PLAN.md: no monolithic or duplicated build
# implementation).

Set-StrictMode -Version Latest

# Bumped on every functional change; scripts print it so logs prove which
# module revision actually executed (SMB caching has served stale files before).
$script:BuildCommonRevision = 'r7-2026-07-23'

function Get-BuildCommonRevision { $script:BuildCommonRevision }

function Get-RepoRoot {
    (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
}

function Assert-WorkRoot {
    # MSYS2 and FFmpeg's build system both fail on paths with spaces and on
    # network drives (SMB permissions/symlinks, and non-deterministic paths in
    # build metadata). The work root must be a space-free path on a local
    # fixed drive -- this is the plan's "stable build root".
    param([Parameter(Mandatory)][string]$WorkRoot)
    if ($WorkRoot -match '\s') {
        throw "WorkRoot '$WorkRoot' contains whitespace. MSYS2/FFmpeg builds break on paths with spaces. Use e.g. C:\nnffb."
    }
    if ($WorkRoot -notmatch '^[A-Za-z]:\\') {
        throw "WorkRoot '$WorkRoot' must be an absolute local path like C:\nnffb (UNC paths are not supported)."
    }
    $drive = New-Object System.IO.DriveInfo ($WorkRoot.Substring(0, 3))
    if ($drive.DriveType -ne [System.IO.DriveType]::Fixed) {
        throw "WorkRoot '$WorkRoot' is on a $($drive.DriveType) drive. The build requires a local fixed drive -- mapped network drives (e.g. Z:) are not supported."
    }
}

function Get-BuildPaths {
    param([Parameter(Mandatory)][string]$WorkRoot)
    [ordered]@{
        workRoot  = $WorkRoot
        downloads = Join-Path $WorkRoot 'downloads'
        msysRoot  = Join-Path $WorkRoot 'msys64'
        sources   = Join-Path $WorkRoot 'sources'
        manifests = Join-Path $WorkRoot 'manifests'
        deps      = Join-Path $WorkRoot 'deps'
        ffbuild   = Join-Path $WorkRoot 'ffbuild'
        dist      = Join-Path $WorkRoot 'dist'
    }
}

function Get-DependencyLockPath {
    param([string]$Path)
    $lockPath = if ([string]::IsNullOrWhiteSpace($Path)) {
        Join-Path (Get-RepoRoot) 'config\dependencies.lock.json'
    } elseif ([System.IO.Path]::IsPathRooted($Path)) {
        $Path
    } else {
        Join-Path (Get-RepoRoot) $Path
    }
    if (-not (Test-Path $lockPath)) { throw "Dependency lock not found: $lockPath" }
    (Resolve-Path $lockPath).Path
}

function Read-DependencyLock {
    param([string]$Path)
    $lockPath = Get-DependencyLockPath $Path
    $lock = Get-Content $lockPath -Raw | ConvertFrom-Json
    if (-not $lock.components) { throw "Dependency lock has no components: $lockPath" }
    $lock
}

function Get-LockComponent {
    param($Lock, [Parameter(Mandatory)][string]$Name)
    $comp = @($Lock.components | Where-Object { $_.name -eq $Name })
    if ($comp.Count -ne 1) { throw "Component '$Name' not found (or duplicated) in dependency lock." }
    $comp[0]
}

function Get-FileSha256 {
    param([Parameter(Mandatory)][string]$Path)
    (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLowerInvariant()
}

function Move-FileAtomically {
    param(
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$Destination
    )
    if (-not (Test-Path -LiteralPath $Source)) {
        throw "Source file for atomic move does not exist: $Source"
    }

    $destinationDirectory = Split-Path -Parent $Destination
    if (-not (Test-Path -LiteralPath $destinationDirectory)) {
        New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
    }

    if (Test-Path -LiteralPath $Destination) {
        $backupPath = "$Destination.replace-backup.$([Guid]::NewGuid().ToString('N'))"
        try {
            [System.IO.File]::Replace($Source, $Destination, $backupPath, $true)
        } finally {
            if (Test-Path -LiteralPath $backupPath) {
                Remove-Item -LiteralPath $backupPath -Force
            }
        }
        return
    }

    try {
        [System.IO.File]::Move($Source, $Destination)
    } catch [System.IO.IOException] {
        if (Test-Path -LiteralPath $Destination) {
            [System.IO.File]::Replace($Source, $Destination, $null, $true)
            return
        }
        throw
    }
}

function Copy-FileAtomically {
    param(
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$Destination
    )
    if (-not (Test-Path -LiteralPath $Source)) {
        throw "Source file for atomic copy does not exist: $Source"
    }

    $destinationDirectory = Split-Path -Parent $Destination
    if (-not (Test-Path -LiteralPath $destinationDirectory)) {
        New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
    }

    $temporaryPath = Join-Path $destinationDirectory (".{0}.{1}.tmp" -f (Split-Path -Leaf $Destination), [Guid]::NewGuid().ToString('N'))
    try {
        [System.IO.File]::Copy($Source, $temporaryPath, $false)
        Move-FileAtomically -Source $temporaryPath -Destination $Destination
    } finally {
        if (Test-Path -LiteralPath $temporaryPath) {
            Remove-Item -LiteralPath $temporaryPath -Force
        }
    }
}

function Test-ObjectProperty {
    # StrictMode-safe check for an optional JSON property.
    param($Object, [Parameter(Mandatory)][string]$Name)
    $null -ne $Object.PSObject.Properties[$Name] -and $null -ne $Object.$Name
}

function Invoke-PinnedDownload {
    <#
      Fail-closed pinned download:
        - HTTPS only, on every hop
        - every redirect hop's host must be in AllowedHosts (unexpected
          redirect = hard failure)
        - SHA-256 verified before the file is accepted; mismatch deletes the
          temp file and throws
        - an existing file with the correct hash is reused (cached=true);
          an existing file with a WRONG hash is treated as corrupt/tampered,
          removed, and re-downloaded
      Returns a record for the verification manifest.
    #>
    param(
        [Parameter(Mandatory)][string]$Url,
        [Parameter(Mandatory)][string]$OutFile,
        [Parameter(Mandatory)][string]$Sha256,
        [Parameter(Mandatory)][string[]]$AllowedHosts,
        [int]$MaxRedirects = 5
    )
    $expected = $Sha256.ToLowerInvariant()
    $leaf = Split-Path -Leaf $OutFile
    $staleRemoved = $false
    $result = $null

    if (Test-Path $OutFile) {
        $actual = Get-FileSha256 $OutFile
        if ($actual -eq $expected) {
            $result = [ordered]@{
                url = $Url; file = $OutFile; sha256 = $actual; bytes = (Get-Item $OutFile).Length
                cached = $true; staleCacheRemoved = $false; redirectChain = @($Url)
            }
        } else {
            Write-Warning "$leaf exists with WRONG hash ($actual) -- removing and re-downloading."
            Remove-Item $OutFile -Force
            $staleRemoved = $true
        }
    }

    if ($null -eq $result) {
        [Net.ServicePointManager]::SecurityProtocol = `
            [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
        Add-Type -AssemblyName System.Net.Http -ErrorAction Stop
        $handler = New-Object System.Net.Http.HttpClientHandler
        $handler.AllowAutoRedirect = $false
        $client = New-Object System.Net.Http.HttpClient($handler)
        $client.Timeout = [TimeSpan]::FromMinutes(30)
        $client.DefaultRequestHeaders.UserAgent.ParseAdd('NextNote-FFmpeg-Build/1.0')

        $tmp = "$OutFile.download"
        try {
            $current = [Uri]$Url
            $chain = @()
            $resp = $null
            $done = $false
            for ($hop = 0; $hop -le $MaxRedirects; $hop++) {
                if ($current.Scheme -ne 'https') {
                    throw "Refusing non-HTTPS URL for ${leaf}: $($current.AbsoluteUri)"
                }
                if ($AllowedHosts -notcontains $current.Host) {
                    throw "Unexpected redirect for ${leaf}: host '$($current.Host)' is not in the allowed list [$($AllowedHosts -join ', ')]. Refusing to download."
                }
                $chain += $current.AbsoluteUri
                $resp = $client.GetAsync($current, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
                $status = [int]$resp.StatusCode
                if ($status -in 301, 302, 303, 307, 308) {
                    $loc = $resp.Headers.Location
                    if ($null -eq $loc) { throw "Redirect without Location header from $($current.AbsoluteUri)" }
                    if (-not $loc.IsAbsoluteUri) { $loc = New-Object Uri($current, $loc) }
                    $resp.Dispose()
                    $current = $loc
                    continue
                }
                if ($status -ne 200) { throw "HTTP $status fetching $($current.AbsoluteUri)" }
                $done = $true
                break
            }
            if (-not $done) { throw "Too many redirects (> $MaxRedirects) fetching $Url" }

            if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force }
            $fs = [System.IO.File]::Create($tmp)
            try {
                $resp.Content.CopyToAsync($fs).GetAwaiter().GetResult()
            } finally {
                $fs.Close()
                $resp.Dispose()
            }

            $actual = Get-FileSha256 $tmp
            if ($actual -ne $expected) {
                Remove-Item $tmp -Force
                throw "SHA-256 MISMATCH for ${leaf}: expected $expected, got $actual. Possible tampering or unpinned upstream change -- refusing to continue."
            }
            Move-Item -Force $tmp $OutFile
            # Assign only -- the single emission happens at the end of the
            # function, outside try/finally. PS 5.1 lost values emitted by
            # 'return' from inside this block when the module ran on the
            # target machine.
            $result = [ordered]@{
                url = $Url; file = $OutFile; sha256 = $actual; bytes = (Get-Item $OutFile).Length
                cached = $false; staleCacheRemoved = $staleRemoved; redirectChain = $chain
            }
        } catch {
            if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force }
            throw
        }
        finally {
            $client.Dispose()
        }
    }

    if ($null -eq $result) {
        throw "Internal error: no result object was produced for $leaf despite verification completing."
    }
    $result
}

function Get-SingleResult {
    # PS 5.1 pipeline-leak guard: a function that should return exactly one
    # object can emit extras if any statement leaks to the output stream.
    # Returns the LAST object (the intended return value) and loudly reports
    # any extras with their types so the leak can be fixed at its source.
    param(
        [AllowNull()][AllowEmptyCollection()][object[]]$Results,
        [Parameter(Mandatory)][string]$Context
    )
    if ($null -eq $Results -or $Results.Count -eq 0) {
        throw "$Context returned NO result object -- the function's output was lost. Module revision: $script:BuildCommonRevision."
    }
    if ($Results.Count -ne 1) {
        Write-Warning ("{0}: expected 1 result object, got {1} -- pipeline leak, extras follow:" -f $Context, $Results.Count)
        for ($i = 0; $i -lt $Results.Count - 1; $i++) {
            $x = $Results[$i]
            $type = if ($null -eq $x) { '(null)' } else { $x.GetType().FullName }
            $val  = if ($null -eq $x) { '' } else { ($x | Out-String).Trim() }
            if ($val.Length -gt 200) { $val = $val.Substring(0, 200) + '...' }
            Write-Warning ("  extra[{0}] {1}: {2}" -f $i, $type, $val)
        }
    }
    $Results[-1]
}

function Invoke-FfmpegProcess {
    # Runs ffmpeg.exe directly (no shell -- avoids quoting/encoding issues
    # seen with cmd.exe on this project). Streams StdinFile to stdin if given
    # and StdoutFile from stdout if given; stdout/stderr are drained
    # concurrently so pipes can never deadlock. Mirrors the approach proven
    # in baseline/capture-gyan-baseline.ps1's Invoke-FfmpegToFile.
    param(
        [Parameter(Mandatory)][string]$FfmpegPath,
        [Parameter(Mandatory)][string]$ArgumentLine,
        [string]$StdoutFile,
        [string]$StdinFile,
        [int]$TimeoutSec = 30
    )
    $psi = New-Object System.Diagnostics.ProcessStartInfo
    $psi.FileName               = $FfmpegPath
    $psi.Arguments              = $ArgumentLine
    $psi.UseShellExecute        = $false
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError  = $true
    $psi.RedirectStandardInput  = [bool]$StdinFile
    $psi.CreateNoWindow         = $true

    $p = [System.Diagnostics.Process]::Start($psi)

    $outStream = if ($StdoutFile) { [System.IO.File]::Create($StdoutFile) } else { [System.IO.Stream]::Null }
    $outTask = $p.StandardOutput.BaseStream.CopyToAsync($outStream)
    $errTask = $p.StandardError.ReadToEndAsync()

    if ($StdinFile) {
        $inStream = [System.IO.File]::OpenRead($StdinFile)
        try { $inStream.CopyTo($p.StandardInput.BaseStream); $p.StandardInput.BaseStream.Flush() } catch {}
        $inStream.Close()
        try { $p.StandardInput.Close() } catch {}
    }

    $timedOut = -not $p.WaitForExit($TimeoutSec * 1000)
    if ($timedOut) { try { $p.Kill() } catch {}; $p.WaitForExit() }

    try { $outTask.Wait(5000) | Out-Null } catch {}
    $outStream.Close()
    $stderr = ''
    try { if ($errTask.Wait(5000)) { $stderr = $errTask.Result } } catch {}

    $exitCode = $p.ExitCode
    $p.Dispose()

    $tail = ''
    if ($stderr) {
        $lines = $stderr -split "`r?`n" | Where-Object { $_ -ne '' }
        $tail  = ($lines | Select-Object -Last 3) -join ' | '
    }
    $result = if ($timedOut) { -1 } else { $exitCode }
    [ordered]@{ exitCode = $result; timedOut = $timedOut; stderrTail = $tail; stderrFull = $stderr }
}

function Get-BuildconfTokens {
    # Parses ffmpeg -buildconf output. Its real format (confirmed against
    # the target machine's actual output during Milestone 2 authoring) is
    # NOT a single "configuration: <all flags>" line like -version uses --
    # it is a "configuration:" header line with nothing after the colon,
    # followed by each flag on its own indented line, until a blank line
    # ends the section:
    #   (blank)
    #     configuration:
    #       --prefix=...
    #       --enable-amf
    #       ...
    #   (blank)
    #     libraries:
    #       ...
    # Assuming the -version single-line format here silently produced an
    # empty token set and every checked flag reported "missing" at once.
    param([Parameter(Mandatory)][string]$Text)
    $lines = $Text -split "`r?`n"
    $headerIdx = $null
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ($lines[$i].Trim() -eq 'configuration:') { $headerIdx = $i; break }
    }
    if ($null -eq $headerIdx) {
        throw "-buildconf output did not contain a 'configuration:' section header."
    }
    $tokens = @()
    for ($i = $headerIdx + 1; $i -lt $lines.Count; $i++) {
        $line = $lines[$i].Trim()
        if ($line -eq '') { break }
        $tokens += $line
    }
    $tokens
}

function ConvertTo-MsysPath {
    # C:\foo\bar -> /c/foo/bar for use inside MSYS2 bash.
    param([Parameter(Mandatory)][string]$Path)
    $full = [System.IO.Path]::GetFullPath($Path)
    if ($full -notmatch '^[A-Za-z]:\\') { throw "Cannot convert to MSYS path: $full" }
    '/' + $full.Substring(0, 1).ToLowerInvariant() + '/' + $full.Substring(3).Replace('\', '/')
}

function Invoke-MsysCommand {
    # Runs a command in an MSYS2 UCRT64 login shell. Throws on non-zero exit.
    # Returns captured stdout lines.
    param(
        [Parameter(Mandatory)][string]$MsysRoot,
        [Parameter(Mandatory)][string]$Command,
        [switch]$AllowFailure
    )
    $bash = Join-Path $MsysRoot 'usr\bin\bash.exe'
    if (-not (Test-Path $bash)) { throw "MSYS2 bash not found at $bash -- run scripts\bootstrap-msys2.ps1 first." }
    $env:MSYSTEM = 'UCRT64'
    $env:CHERE_INVOKING = '1'
    # PS 5.1 turns native stderr output into a terminating error when 2>&1 is
    # combined with ErrorActionPreference=Stop; pacman/gpg write progress to
    # stderr, so relax the preference for the invocation only.
    $ErrorActionPreference = 'Continue'
    $out = & $bash -l -c $Command 2>&1
    $ErrorActionPreference = 'Stop'
    if ($LASTEXITCODE -ne 0 -and -not $AllowFailure) {
        $tail = ($out | Select-Object -Last 10) -join "`n"
        throw "MSYS2 command failed (exit $LASTEXITCODE): $Command`n$tail"
    }
    $out
}

function Write-JsonFile {
    # Write JSON atomically (temp + move) -- direct overwrites over SMB have
    # produced corrupt interleaved files in this project before.
    param([Parameter(Mandatory)]$Object, [Parameter(Mandatory)][string]$Path, [int]$Depth = 8)
    $tmp = "$Path.tmp"
    [System.IO.File]::WriteAllText($tmp, ($Object | ConvertTo-Json -Depth $Depth), (New-Object System.Text.UTF8Encoding($false)))
    Move-FileAtomically -Source $tmp -Destination $Path
}

Export-ModuleMember -Function Get-RepoRoot, Get-BuildCommonRevision, Assert-WorkRoot, Get-BuildPaths, Get-DependencyLockPath, `
    Read-DependencyLock, Get-LockComponent, Get-FileSha256, Test-ObjectProperty, Invoke-PinnedDownload, `
    Get-SingleResult, Invoke-FfmpegProcess, Get-BuildconfTokens, ConvertTo-MsysPath, Invoke-MsysCommand, `
    Move-FileAtomically, Copy-FileAtomically, Write-JsonFile
