#requires -Version 7
<#
.SYNOPSIS
    Audit pack.lock.json for missing required mod dependencies by reading
    each jar's mods.toml (and any jar-in-jar bundles) directly. Loader-truth,
    not Modrinth-truth.

.DESCRIPTION
    Each NeoForge/Forge mod declares its real dependencies in
    META-INF/neoforge.mods.toml (or META-INF/mods.toml) inside the jar.
    This is what the loader actually checks at startup; the Modrinth
    dependency field is metadata authors fill in (or don't).

    The script also recurses into META-INF/jarjar/ bundles. Big mods like
    Create ship their deps (Registrate, Flywheel, Ponder) embedded as
    jar-in-jar -- the loader auto-loads them, so their modIds count as
    "present" without explicit lockfile entries.

    Slug filter: only entries under "mods/" are treated as mod jars.
    Shaderpacks and other managed-but-non-mod files are skipped.

    Language-provider jars (e.g. Kotlin for Forge) ship without a standard
    mods.toml; their modIds are added via a tiny override map.

    Jars are cached in /tmp/bns-check-deps-cache/<sha1>.jar so subsequent
    runs are instant.

.PARAMETER LockPath
.PARAMETER CacheDir
.PARAMETER WarnOnly
    Print missing deps but exit 0. Deploy-Brass.ps1 calls without it.
#>

[CmdletBinding()]
param(
    [string]$LockPath = $(Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\pack\pack.lock.json'),
    [string]$CacheDir = '/tmp/bns-check-deps-cache',
    [switch]$WarnOnly
)

$ErrorActionPreference = 'Stop'
$ProgressPreference    = 'SilentlyContinue'

$LockPath = (Resolve-Path $LockPath).Path
$lock = Get-Content $LockPath -Raw | ConvertFrom-Json -Depth 20
if (-not $lock.mods) { throw "pack.lock.json has no 'mods' array." }
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir | Out-Null }

# Vanilla / loader IDs are always satisfied -- they're never modpack mods.
$vanillaIds = @('minecraft','neoforge','forge','fabric','fabricloader','quilt','java','javafml','lowcodefml')

# Language-provider / library jars that ship without a standard mods.toml.
# Map their lockfile slug to the modId they register at runtime. Add entries
# here as new ones are encountered.
$slugToImplicitModId = @{
    'kotlin-for-forge' = 'kotlinforforge'
}

Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem

function Get-JarPath {
    param($mod)
    $cached = Join-Path $CacheDir ("$($mod.sha1).jar")
    if (Test-Path $cached) { return $cached }
    Write-Host "  downloading $($mod.slug) ..." -ForegroundColor DarkGray
    try   { Invoke-WebRequest -Uri $mod.url -OutFile $cached -TimeoutSec 60 }
    catch { throw "Failed to download $($mod.url): $($_.Exception.Message)" }
    return $cached
}

# Read a specific entry's bytes from an already-open ZipArchive into memory.
function Read-ZipEntry {
    param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
    $entry = $Zip.GetEntry($EntryName)
    if (-not $entry) { return $null }
    $ms = New-Object System.IO.MemoryStream
    $s = $entry.Open()
    try { $s.CopyTo($ms) } finally { $s.Dispose() }
    return $ms.ToArray()
}

function Read-ZipEntryText {
    param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
    $entry = $Zip.GetEntry($EntryName)
    if (-not $entry) { return $null }
    $reader = New-Object System.IO.StreamReader($entry.Open())
    try   { return $reader.ReadToEnd() }
    finally { $reader.Dispose() }
}

# Tiny TOML reader -- only the bits we need (sections + key="value" lines).
function Parse-ModsToml {
    param([string]$Text)
    $modIds   = @()
    $deps     = @()
    $section  = $null
    $current  = $null
    function Flush-Record {
        param($refModIds, $refDeps, $sec, $cur)
        if (-not $cur) { return }
        if ($sec -eq 'mods' -and $cur.modId) {
            $refModIds.Value += $cur.modId
        } elseif ($sec -like 'dep:*' -and $cur.modId) {
            $refDeps.Value += [pscustomobject]@{
                parent       = $sec.Substring(4)
                modId        = $cur.modId
                type         = $cur.type
                mandatory    = $cur.mandatory
                versionRange = $cur.versionRange
            }
        }
    }
    $modIdsRef = [ref]$modIds
    $depsRef   = [ref]$deps
    foreach ($raw in ($Text -split "`n")) {
        $line = $raw.Trim()
        if (-not $line -or $line.StartsWith('#')) { continue }
        if ($line.StartsWith('[[')) {
            Flush-Record $modIdsRef $depsRef $section $current
            $current = $null
            if ($line -match '^\[\[mods\]\]') {
                $section = 'mods';  $current = @{}
            } elseif ($line -match '^\[\[dependencies\.([^\]]+)\]\]') {
                $section = "dep:$($Matches[1])";  $current = @{}
            } else {
                $section = $null
            }
            continue
        }
        if (-not $current) { continue }
        if ($line -match '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$') {
            $k = $Matches[1]; $v = $Matches[2].Trim()
            if ($v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) }
            $current[$k] = $v
        }
    }
    Flush-Record $modIdsRef $depsRef $section $current
    return [pscustomobject]@{ ModIds = $modIdsRef.Value; Deps = $depsRef.Value }
}

function Is-Required {
    param($dep)
    if ($dep.type)      { return ($dep.type -eq 'required' -or $dep.type -eq 'mandatory') }
    if ($dep.mandatory) { return ($dep.mandatory -eq 'true') }
    return $true   # NeoForge default
}

# Recursively walk a jar (and its META-INF/jarjar/ embedded jars) collecting
# every modId declared by mods.toml inside. Used to satisfy deps that ship
# bundled (e.g. ponder/registrate/flywheel inside Create).
function Collect-Jar-Identity {
    param([string]$JarPath)
    $modIds = @()
    $deps   = @()
    $zip = [System.IO.Compression.ZipFile]::OpenRead($JarPath)
    try {
        # 1) This jar's own mods.toml (if any).
        $toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/neoforge.mods.toml'
        if (-not $toml) { $toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/mods.toml' }
        if ($toml) {
            $parsed = Parse-ModsToml -Text $toml
            $modIds += $parsed.ModIds
            $deps   += $parsed.Deps
        }
        # 2) jar-in-jar bundles -- recursively extract + collect modIds (we only
        #    need their modIds; their deps are the parent's problem to satisfy).
        $jarjarEntries = $zip.Entries | Where-Object {
            $_.FullName -like 'META-INF/jarjar/*.jar'
        }
        foreach ($e in $jarjarEntries) {
            $tmpInner = [System.IO.Path]::GetTempFileName() + '.jar'
            $s = $e.Open()
            try {
                $fs = [System.IO.File]::Open($tmpInner, [System.IO.FileMode]::Create)
                try { $s.CopyTo($fs) } finally { $fs.Dispose() }
            } finally { $s.Dispose() }
            $inner = Collect-Jar-Identity -JarPath $tmpInner
            $modIds += $inner.ModIds   # bundled deps count as present
            # Don't propagate bundled deps -- they're satisfied within this jar
            Remove-Item $tmpInner -Force -ErrorAction SilentlyContinue
        }
    } finally {
        $zip.Dispose()
    }
    return [pscustomobject]@{ ModIds = ($modIds | Select-Object -Unique); Deps = $deps }
}

Write-Host ""
Write-Host "Auditing $($lock.mods.Count) lockfile entries (parsing each jar's mods.toml + jar-in-jar bundles)..."
Write-Host ""

$presentModIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($v in $vanillaIds) { [void]$presentModIds.Add($v) }

# parent modId -> list of dep records (so the missing report blames a modId, not a slug)
$jarDepsByParentModId = @{}
$skipped = @()   # informational

$count = 0
foreach ($m in $lock.mods) {
    $count++
    # Only consider entries that live under mods/ -- shaderpacks, configs etc.
    # are managed by the launcher but aren't mods.
    if (-not ($m.path -like 'mods/*')) {
        $skipped += "$($m.slug): not under mods/ ($($m.path))"
        continue
    }
    # Language-provider override (kotlin-for-forge etc.)
    if ($slugToImplicitModId.ContainsKey($m.slug)) {
        [void]$presentModIds.Add($slugToImplicitModId[$m.slug])
    }
    try {
        $jar = Get-JarPath -mod $m
        $ident = Collect-Jar-Identity -JarPath $jar
        foreach ($id in $ident.ModIds) {
            [void]$presentModIds.Add($id)
        }
        foreach ($id in $ident.ModIds) {
            $jarDepsByParentModId[$id] = $ident.Deps
        }
        if (-not $ident.ModIds -and -not $slugToImplicitModId.ContainsKey($m.slug)) {
            $skipped += "$($m.slug): no modId discovered (no mods.toml; consider adding to override map)"
        }
    } catch {
        $skipped += "$($m.slug): $($_.Exception.Message)"
    }
    if ($count % 10 -eq 0) { Write-Host "  ...processed $count/$($lock.mods.Count)" -ForegroundColor DarkGray }
}

# Pass 2: check each parent's required deps against the present set.
$missing = @{}
foreach ($parentId in $jarDepsByParentModId.Keys) {
    foreach ($dep in $jarDepsByParentModId[$parentId]) {
        if (-not (Is-Required $dep)) { continue }
        if (-not $presentModIds.Contains($dep.modId)) {
            if (-not $missing.ContainsKey($dep.modId)) {
                $missing[$dep.modId] = @{ parents = @() }
            }
            $missing[$dep.modId].parents += $parentId
        }
    }
}

Write-Host ""
Write-Host ("=" * 60)
if ($missing.Count -eq 0) {
    Write-Host "All required deps satisfied. ($($presentModIds.Count) mod IDs in pack, incl. bundled jar-in-jar)" -ForegroundColor Green
} else {
    Write-Host "MISSING REQUIRED DEPS: $($missing.Count) unique" -ForegroundColor Red
    Write-Host ""
    foreach ($depId in ($missing.Keys | Sort-Object)) {
        $parents = ($missing[$depId].parents | Sort-Object -Unique) -join ', '
        Write-Host "  $depId" -ForegroundColor Red
        Write-Host "    needed by: $parents" -ForegroundColor DarkGray
    }
}

if ($skipped.Count -gt 0) {
    Write-Host ""
    Write-Host "Informational (skipped / unanalysable):" -ForegroundColor DarkGray
    foreach ($s in $skipped) { Write-Host "  - $s" -ForegroundColor DarkGray }
}

if ($missing.Count -gt 0 -and -not $WarnOnly) {
    throw "Missing required dependencies. Add them to pack/pack.lock.json (or pass -WarnOnly to bypass)."
}
