# Copyright (c) 2026 Progralink. Licensed under the DevCage License — see LICENSE.
#
# Build.ps1 — Image fingerprinting and build-lock functions for the DevCage
# PowerShell launcher. Dot-sourced by devcage.ps1.
# Depends on: $ScriptDir, $ContainerEngine, $DevcageVersion, $VersionBuildArgs,
#             $Rebuild, $SkipBuild (script-scope), $FPCacheFile, $ImageName,
#             $BuildLockFile, $dcPaths, $hasSubpath, $WorkspaceSlug.

# ── Build lock state ──────────────────────────────────────────────
$script:BuildLockStream = $null

# ── Build state initialization ────────────────────────────────────
# Called after SandboxTmpDir is created and WorkspaceSlug/DEVCAGE_IMAGE are resolved.
# Sets: $script:StateDir, $script:FPCacheFile, $script:ImageName, $script:BuildLockFile
function Initialize-BuildState {
    $script:StateDir = Join-Path (Join-Path $env:WORKSPACE ".devcage") "local"
    if (-not (Test-Path $script:StateDir)) {
        New-Item -ItemType Directory -Path $script:StateDir -Force | Out-Null
    }
    # Ensure .devcage/local is git-ignored
    $localGitignore = Join-Path $script:StateDir ".gitignore"
    if (-not (Test-Path $localGitignore)) {
        try { Set-Content -Path $localGitignore -Value '*' -NoNewline -ErrorAction SilentlyContinue } catch {}
    }
    $script:FPCacheFile = Join-Path $script:StateDir "fingerprint.cache"
    $script:ImageName = if ($env:DEVCAGE_IMAGE) { $env:DEVCAGE_IMAGE } else { "devcage-workspace" }
    $lockDir = Join-Path $HOME ".devcage"
    if (-not (Test-Path $lockDir)) { New-Item -ItemType Directory -Path $lockDir -Force | Out-Null }
    $imageNameSafe = $script:ImageName -replace '[^a-zA-Z0-9._-]', '-'
    $script:BuildLockFile = Join-Path $lockDir "devcage-build-$imageNameSafe.lock"
}

# ── Fingerprint helpers ───────────────────────────────────────────
function Get-FPMtimeKey {
    # Stat individual files so edits, adds, and removes are detected.
    $mtimes = New-Object System.Text.StringBuilder
    $dockerfilePath = Join-Path (Join-Path (Join-Path $ScriptDir "sandbox") "image") "Dockerfile"
    $composePath = Join-Path (Join-Path $ScriptDir "sandbox") "docker-compose.yml"
    foreach ($f in @($dockerfilePath, $composePath)) {
        if (Test-Path $f) {
            [void]$mtimes.Append((Get-Item $f).LastWriteTimeUtc.Ticks.ToString() + ",")
        }
    }
    $wrappersDir = Join-Path (Join-Path (Join-Path $ScriptDir "sandbox") "image") "wrappers"
    if (Test-Path $wrappersDir) {
        Get-ChildItem $wrappersDir -Recurse -File | Sort-Object FullName | ForEach-Object {
            [void]$mtimes.Append($_.LastWriteTimeUtc.Ticks.ToString() + ",")
        }
    }
    # Include build args so version pin changes invalidate cache
    foreach ($arg in $VersionBuildArgs) { [void]$mtimes.Append($arg) }
    return $mtimes.ToString()
}

function Get-BuildFingerprint {
    # Fast path: use cached fingerprint if build input mtimes haven't changed
    if (Test-Path $FPCacheFile) {
        $cacheLines = @(Get-Content $FPCacheFile -ErrorAction SilentlyContinue)
        if ($cacheLines -and $cacheLines.Count -ge 2) {
            $cachedMtime = $cacheLines[0]
            $cachedFP = $cacheLines[1]
            if ($cachedFP -and $cachedMtime -and (Get-FPMtimeKey) -eq $cachedMtime) {
                return $cachedFP
            }
        }
    }
    # Slow path: compute full fingerprint from file contents
    $hashInput = New-Object System.Text.StringBuilder
    $dockerfilePath = Join-Path (Join-Path (Join-Path $ScriptDir "sandbox") "image") "Dockerfile"
    if (Test-Path $dockerfilePath) { [void]$hashInput.AppendLine((Get-Content $dockerfilePath -Raw)) }
    $wrappersDir = Join-Path (Join-Path (Join-Path $ScriptDir "sandbox") "image") "wrappers"
    if (Test-Path $wrappersDir) {
        Get-ChildItem $wrappersDir -Recurse -File | Sort-Object FullName | ForEach-Object {
            [void]$hashInput.AppendLine((Get-Content $_.FullName -Raw))
        }
    }
    foreach ($arg in $VersionBuildArgs) { [void]$hashInput.AppendLine($arg) }
    $composePath = Join-Path (Join-Path $ScriptDir "sandbox") "docker-compose.yml"
    if (Test-Path $composePath) { [void]$hashInput.AppendLine((Get-Content $composePath -Raw)) }
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($hashInput.ToString())
        $fp = [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace("-", "").ToLowerInvariant()
    } finally {
        $sha.Dispose()
    }
    # Cache for next run (line 1 = mtime key, line 2 = fingerprint hash)
    try { Set-Content -Path $FPCacheFile -Value @((Get-FPMtimeKey), $fp) -ErrorAction SilentlyContinue } catch {}
    return $fp
}

# ── Build lock functions ─────────────────────────────────────────
# Prevent concurrent builds of the same image from stomping each other.
function Enter-BuildLock {
    if ($script:BuildLockStream) { return }
    $waited = $false
    $timeout = [datetime]::Now.AddMinutes(10)
    while ($true) {
        try {
            $script:BuildLockStream = [System.IO.File]::Open(
                $BuildLockFile,
                [System.IO.FileMode]::OpenOrCreate,
                [System.IO.FileAccess]::ReadWrite,
                [System.IO.FileShare]::None
            )
            return
        } catch [System.IO.IOException] {
            if ([datetime]::Now -gt $timeout) {
                Write-Host "Warning: build lock timed out after 10 minutes, proceeding anyway." -ForegroundColor Yellow
                return
            }
            if (-not $waited) {
                [Console]::Error.WriteLine("Waiting for another DevCage build to finish...")
                $waited = $true
            }
            Start-Sleep -Seconds 2
        }
    }
}

function Exit-BuildLock {
    if ($script:BuildLockStream) {
        try { $script:BuildLockStream.Close() } catch {}
        try { $script:BuildLockStream.Dispose() } catch {}
        $script:BuildLockStream = $null
        try { Remove-Item -Path $BuildLockFile -Force -ErrorAction SilentlyContinue } catch {}
    }
}

# ── Image label inspection ────────────────────────────────────────
# Read both labels from a container image in a single inspect call (avoids
# multiple docker round-trips through WSL2, saving 2-4 seconds on Windows).
function Get-ImageLabels {
    $prevEAP = $ErrorActionPreference
    try {
        $ErrorActionPreference = "SilentlyContinue"
        $jsonStr = & $ContainerEngine image inspect --format '{{json .Config.Labels}}' $ImageName 2>$null
    } finally {
        $ErrorActionPreference = $prevEAP
    }
    if ($LASTEXITCODE -eq 0 -and $jsonStr) {
        try {
            $labels = $jsonStr | ConvertFrom-Json
            $ver = $labels.'dev-sandbox.version'
            $fp = $labels.'dev-sandbox.fingerprint'
            if ($ver) {
                return @{ Version = $ver; Fingerprint = if ($fp) { $fp } else { "" } }
            }
        } catch {}
    }
    return $null
}

# ── Build decision + build execution ─────────────────────────────
# Checks image labels against current version + fingerprint.
# If build needed: acquires lock, re-checks after lock, builds, releases lock.
# Then pre-creates dircache subdirs in the cache volume.
# Reads/sets: $script:SkipBuild
function Invoke-BuildDecision {
    $currentFP = $null
    if (-not $Rebuild) {
        $labels = Get-ImageLabels
        if ($labels) {
            $currentFP = Get-BuildFingerprint
            $labelVer = $labels.Version
            $labelFP = $labels.Fingerprint
            if ($labelFP -and $labelFP -ne "unknown") {
                if ($labelVer -eq $DevcageVersion -and $labelFP -eq $currentFP) {
                    # Fast path: image matches, no build needed
                    if (-not $SkipBuild) { $script:SkipBuild = $true }
                } else {
                    if ($labelVer -ne $DevcageVersion) {
                        [Console]::Error.WriteLine("DevCage version changed ($labelVer -> $DevcageVersion): rebuilding image...")
                    } else {
                        [Console]::Error.WriteLine("Build inputs changed: rebuilding image...")
                    }
                    $script:SkipBuild = $false
                }
            } else {
                # Image has no labels — must rebuild to add them
                [Console]::Error.WriteLine("Image missing fingerprint labels: rebuilding image...")
                $script:SkipBuild = $false
            }
        } else {
            # Image missing — must build regardless of --skip-build
            if ($SkipBuild) {
                Write-Host "Warning: image '$ImageName' not found, ignoring --skip-build." -ForegroundColor Yellow
            }
            $script:SkipBuild = $false
        }
    }

    if ($SkipBuild -and $Rebuild) {
        Write-Host "Warning: --skip-build and --rebuild both set; skipping build." -ForegroundColor Yellow
    }

    if (-not $SkipBuild) {
        Enter-BuildLock
        # Re-check image labels after acquiring lock — another builder may have finished
        if (-not $Rebuild) {
            $recheckLabels = Get-ImageLabels
            if ($recheckLabels) {
                $recheckVer = $recheckLabels.Version
                $recheckFP = $recheckLabels.Fingerprint
                if (-not $currentFP) { $currentFP = Get-BuildFingerprint }
                if ($recheckFP -and $recheckFP -ne "unknown" -and $recheckVer -eq $DevcageVersion -and $recheckFP -eq $currentFP) {
                    [Console]::Error.WriteLine("Image was built by another instance while waiting; skipping build.")
                    $script:SkipBuild = $true
                    Exit-BuildLock
                }
            }
        }
    }

    if (-not $SkipBuild) {
        $BuildArgs = @()
        $FPBuildArgs = @("--build-arg", "DEVCAGE_VERSION=$DevcageVersion", "--build-arg", "DEVCAGE_FINGERPRINT=$(Get-BuildFingerprint)")
        if ($Rebuild) {
            $BuildArgs += "--no-cache"
            Write-Host "Rebuilding image from scratch (may take several minutes)..."
        } else {
            [Console]::Error.WriteLine("Building sandbox image (first run may take several minutes)...")
        }
        # Remove existing image tag so BuildKit can re-create it without conflict.
        $prevEAP2 = $ErrorActionPreference
        try {
            $ErrorActionPreference = "SilentlyContinue"
            & $ContainerEngine image rm $ImageName 2>&1 | Out-Null
        } finally {
            $ErrorActionPreference = $prevEAP2
        }
        & $ContainerEngine compose @BuildProgressArgs @ComposeFiles build --quiet @BuildArgs @VersionBuildArgs @FPBuildArgs
        if ($LASTEXITCODE -ne 0) {
            & $ContainerEngine compose @BuildProgressArgs @ComposeFiles build @BuildArgs @VersionBuildArgs @FPBuildArgs
            if ($LASTEXITCODE -ne 0) {
                Write-Host "Error: Docker image build failed." -ForegroundColor Red
                Exit-BuildLock
                exit 1
            }
        }
        Exit-BuildLock
        # Prune dangling images left by rebuild
        if ($Rebuild) {
            $prevEAP = $ErrorActionPreference
            try {
                $ErrorActionPreference = "SilentlyContinue"
                & $ContainerEngine image prune -f *>$null
            } finally {
                $ErrorActionPreference = $prevEAP
            }
        }
    }

    # Pre-create dircache subdirectories inside the cache volume.
    # Docker volume-subpath requires the subpath directory to already exist in the volume.
    if ($hasSubpath -and $dcPaths.Count -gt 0) {
        $mkdirPaths = $dcPaths | ForEach-Object { "/vol/dircache/$_" }
        $cacheVolName = "devcage_${WorkspaceSlug}_cache"
        # Shell-escape each path to prevent injection via crafted directory names
        $escapedPaths = $mkdirPaths | ForEach-Object { "'" + ($_ -replace "'", "'\\''") + "'" }
        $mkdirCmd = "mkdir -p " + ($escapedPaths -join ' ')
        $prevEAP3 = $ErrorActionPreference
        try {
            $ErrorActionPreference = "SilentlyContinue"
            # Ensure volume exists before running mkdir
            & $ContainerEngine volume create $cacheVolName *>$null
            & $ContainerEngine run --rm `
                --entrypoint /bin/sh `
                --user root `
                -v "${cacheVolName}:/vol" `
                $ImageName -c $mkdirCmd *>$null
        } finally {
            $ErrorActionPreference = $prevEAP3
        }
        if ($LASTEXITCODE -ne 0) {
            Write-Warning "Failed to pre-create dircache dirs in cache volume; directory caches may not work"
        }
    }
}
