# update-yt-dlp-nightly.ps1
#
# Installs the latest yt-dlp NIGHTLY build as a standalone yt-dlp.exe.
# Invoked by update-yt-dlp-nightly.bat - the two files ship together and
# must stay in the same directory. See that .bat file's header comment
# for why nightly, the trade-offs, and how to schedule this yourself
# (Task Scheduler, your BBS software's own event scheduler, etc.) - this
# script does none of that scheduling itself, just the one
# update-check-and-swap when invoked.
#
# Written for PowerShell 2.0 compatibility - confirmed live: a real
# Windows 7 SP1 BBS box still runs the PowerShell 2.0 that ships with the
# OS (dated 2009), not the 3.0+ this script originally assumed. That rules
# out Invoke-RestMethod, Invoke-WebRequest, ConvertFrom-Json, and
# $PSScriptRoot - all PowerShell 3.0+ additions. Replaced below with
# System.Net.WebClient (or curl.exe, see below) + manual regex extraction
# from the raw JSON text, the same technique the Linux
# update-yt-dlp-nightly.sh already uses (grep+sed) for the identical
# reason: don't depend on a JSON parser being available.
#
# Also confirmed live on that same box: its .NET Framework's own
# [System.Net.SecurityProtocolType] enum only defines Ssl3 and Tls (1.0) -
# no TLS 1.1/1.2 at all, which GitHub requires. That's a genuine OS-level
# gap (confirmed independently via certutil, which uses a completely
# separate networking stack from .NET and failed identically) - no amount
# of PowerShell/.NET configuration can add TLS 1.2 support that isn't
# there. If curl.exe (with its own bundled, TLS-1.2-capable library, not
# Windows' own) is present next to this script, it's used instead of
# WebClient for both HTTPS calls below, sidestepping the OS's TLS
# limitation entirely. Falls back to WebClient automatically if curl.exe
# is missing, or if it's present but fails to even run - which can happen
# on a REALLY old Windows 7 box: curl-for-win's official builds are built
# against the Universal C Runtime (UCRT), which Windows 7 didn't ship
# with by default (it needs KB2999226 or later, or Windows 8.1+/10+). A
# box old enough to lack TLS 1.2 support might also lack UCRT - if so,
# curl.exe itself won't start, and this falls back to WebClient (which
# will then fail on the TLS issue as before, with a clear error) rather
# than leaving a confusing "missing DLL" error unexplained.
#
# Only the 64-bit Windows packages ship curl.exe: curl-for-win's official
# builds dropped 32-bit Windows entirely (current releases are x64/ARM64
# only, and even their last archived 32-bit build has since been removed
# from their server). No trustworthy prebuilt 32-bit alternative was
# available, so the 32-bit packages don't include one - Test-Path below
# simply finds nothing and this always falls back to WebClient there.
#
# Usage:
#   powershell -File update-yt-dlp-nightly.ps1                          (updates yt-dlp.exe next to this script)
#   powershell -File update-yt-dlp-nightly.ps1 "C:\path\to\yt-dlp.exe"  (updates a specific file instead)
#
# Exit code: 0 = success or already current, 1 = failed (yt-dlp.exe left
# untouched either way - this never leaves a partial/broken file in place).

param(
    [string]$Target = ""
)

# Which GitHub repo's "latest release" API to query. Real, hard limit
# found live: official yt-dlp requires Python 3.9+ as of yt-dlp/yt-dlp#10086,
# which raised the minimum OS to Windows 8 - Python 3.9+ needs
# api-ms-win-core-path-l1-1-0.dll, an API set Microsoft never backported
# to Windows 7 (confirmed against KB2999226's and KB2533623's own real
# manifests - neither one provides it, despite both commonly being cited
# online as "the fix"; this is not a missing hotfix, it's a genuine
# upstream compatibility drop). Official yt-dlp's own last Windows
# 7-compatible release is frozen at 2024-10-31 and gets no further
# updates. For win7 packages this is overridden to
# "https://api.github.com/repos/nicolaasjan/yt-dlp/releases/latest" - a
# third-party fork that specifically keeps building for Windows 7/XP and
# tracks upstream's own fixes closely (its release tags use the same
# dated nightly-style versioning as upstream's own nightly channel).
$ApiUrl = "https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest"

# Which release asset to fetch. Real bug found live: yt-dlp's plain
# "yt-dlp.exe" release asset is a 64-bit build - running it on a 32-bit
# Windows install fails with "[PYI-xxx:ERROR] Failed to load Python DLL
# ... LoadLibrary: The specified module could not be found", a
# PyInstaller error that (misleadingly) looks like a missing/corrupt file
# rather than a plain architecture mismatch. The 32-bit build is a
# SEPARATE asset, "yt-dlp_x86.exe" - this is set to the right one for
# whichever package this script ships in (32-bit packages have this
# overridden to "yt-dlp_x86.exe", win7 packages to
# "yt-dlp_x86_win7.exe"/"yt-dlp_win7.exe"; see packaging notes). $Target
# below is always still just "yt-dlp.exe" regardless - only the name
# searched for on GitHub changes, not the local filename this gets saved
# as.
$AssetName = "yt-dlp.exe"

# $PSScriptRoot doesn't exist in PowerShell 2.0 for scripts - this is the
# PS2-compatible equivalent, needed both for the default $Target below and
# for locating a bundled curl.exe next to this script.
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path

if (-not $Target) {
    $Target = Join-Path $scriptDir "yt-dlp.exe"
}

function Log($msg) {
    Write-Host "update-yt-dlp-nightly: $msg"
}

Log "target is $Target"

# GitHub's API/CDN requires TLS 1.2. Old .NET defaults to SSL3/TLS1.0
# unless told otherwise, and PowerShell 2.0's own
# [System.Net.SecurityProtocolType] enum may not even define a Tls12
# member by name (that name was only added in .NET 4.5) - assigning the
# raw numeric value sidesteps needing the named constant to exist at all.
# If the underlying OS/.NET stack genuinely can't do TLS 1.2, this throws
# and is caught below - the curl.exe path (if available) is what actually
# recovers from that; this is just best-effort for the WebClient fallback.
try {
    [System.Net.ServicePointManager]::SecurityProtocol = 3072
} catch {
    Log "warning: could not force TLS 1.2 in .NET ($_) - will rely on curl.exe if available, otherwise this will likely fail below"
}

$curlExe = Join-Path $scriptDir "curl.exe"
$curlCaBundle = Join-Path $scriptDir "curl-ca-bundle.crt"
$useCurl = (Test-Path $curlExe) -and (Test-Path $curlCaBundle)
if ($useCurl) {
    Log "using bundled curl.exe for HTTPS (own TLS library, independent of this system's .NET/OS TLS support)"
}

$wc = New-Object System.Net.WebClient
# GitHub's API rejects requests with no User-Agent header at all (403).
# Invoke-RestMethod sends one automatically; WebClient does not, so it
# has to be set explicitly here. curl.exe gets the same via --user-agent
# below, for consistency/traceability on GitHub's side either way.
$wc.Headers.Add("User-Agent", "sixeldoor-update-yt-dlp-nightly")

# Fetches $url as text. Tries curl.exe first if available (own TLS,
# unaffected by this system's .NET/OS TLS limitations); falls back to
# WebClient if curl.exe is missing, or present but fails to even run
# (e.g. a Windows 7 box old enough to also lack the Universal C Runtime
# curl-for-win's builds need).
function Fetch-Text([string]$url) {
    if ($useCurl) {
        try {
            $lines = & $curlExe -fsSL --cacert $curlCaBundle --user-agent "sixeldoor-update-yt-dlp-nightly" $url 2>$null
            if ($LASTEXITCODE -eq 0) {
                return ($lines -join "`n")
            }
            Log "curl.exe exited with code $LASTEXITCODE fetching $url - falling back to WebClient for this request"
        } catch {
            Log "curl.exe invocation failed ($_) - falling back to WebClient for this request"
        }
    }
    return $wc.DownloadString($url)
}

# Downloads $url to $dest. Same curl.exe-first, WebClient-fallback logic
# as Fetch-Text above.
function Fetch-File([string]$url, [string]$dest) {
    if ($useCurl) {
        try {
            & $curlExe -fsSL --cacert $curlCaBundle --user-agent "sixeldoor-update-yt-dlp-nightly" -o $dest $url 2>$null
            if ($LASTEXITCODE -eq 0 -and (Test-Path $dest)) {
                return
            }
            Log "curl.exe exited with code $LASTEXITCODE downloading $url - falling back to WebClient for this request"
        } catch {
            Log "curl.exe invocation failed ($_) - falling back to WebClient for this request"
        }
    }
    $wc.DownloadFile($url, $dest)
}

$currentVersion = "unknown"
if (Test-Path $Target) {
    try {
        $currentVersion = ((& $Target --version) | Out-String).Trim()
    } catch {
        # Leave $currentVersion as "unknown" - treated the same as a
        # missing/first-run install below.
    }
}
Log "current version: $currentVersion"

try {
    $json = Fetch-Text $ApiUrl
} catch {
    Log "ERROR: failed to reach the GitHub API: $_"
    exit 1
}

$tagMatch = [regex]::Match($json, '"tag_name"\s*:\s*"([^"]+)"')
if (-not $tagMatch.Success) {
    Log "ERROR: could not parse a release tag from the GitHub API response"
    exit 1
}
$tag = $tagMatch.Groups[1].Value

if ($currentVersion -eq $tag) {
    Log "already on latest nightly ($tag) - nothing to do"
    exit 0
}

Log "installing nightly $tag (was: $currentVersion)"

# Find $AssetName's download URL (and declared size) directly in the raw
# JSON text - same reasoning as the tag_name extraction above, no JSON
# parser needed. Matches a `"name": "<AssetName>"` entry and the `"size"`
# and `browser_download_url` fields that follow it within the same asset
# object (GitHub's API always emits size before browser_download_url).
# Deliberately [\s\S]*? (any character, non-greedy), NOT [^\}]*? -
# confirmed live against a real GitHub API response: each asset also has
# a large nested "uploader" object between "name" and "browser_download_
# url", full of its own `}` characters, which made an earlier [^\}]*?
# version never match at all. Non-greedy [\s\S]*? still lands on the
# correct (nearest, i.e. this asset's own) fields rather than overrunning
# into the next asset, since fields within one asset object are
# contiguous.
$assetPattern = '"name"\s*:\s*"' + [regex]::Escape($AssetName) + '"[\s\S]*?"size"\s*:\s*(\d+)[\s\S]*?"browser_download_url"\s*:\s*"([^"]+)"'
$assetMatch = [regex]::Match($json, $assetPattern)
if (-not $assetMatch.Success) {
    Log "ERROR: no $AssetName asset found on the latest nightly release"
    exit 1
}
$expectedSize = [int64]$assetMatch.Groups[1].Value
$downloadUrl = $assetMatch.Groups[2].Value

# Download next to the real target, not into $env:TEMP: Move-Item below
# needs source and destination on the same volume for the replace to be
# an atomic rename rather than a copy+delete, exactly like the Linux
# script's equivalent same-directory-temp-file approach.
$tmp = "$Target.new"

# Real failure found live: on one real Windows box (32-bit Windows 10,
# no bundled curl.exe - see the 32-bit note above, so this always goes
# through WebClient there) the freshly-downloaded file consistently
# reported an empty version string on every attempt, while the
# already-installed OLD yt-dlp.exe kept working fine - i.e. this specific
# download was bad, not a transient launch race. WebClient.DownloadFile
# does no integrity checking of its own, so a dropped connection mid-
# transfer silently leaves a truncated .exe that still "runs" (the OS can
# start it) but crashes immediately with no output. Comparing the
# downloaded file's size against the size GitHub's API already told us to
# expect catches that case directly instead of guessing - and retries the
# DOWNLOAD (not just re-launching the same broken file) when it happens.
$downloadOk = $false
for ($attempt = 1; $attempt -le 2; $attempt++) {
    try {
        Fetch-File $downloadUrl $tmp
    } catch {
        Log "ERROR: download failed: $_"
        Remove-Item -ErrorAction SilentlyContinue $tmp
        exit 1
    }
    $actualSize = (Get-Item $tmp -ErrorAction SilentlyContinue).Length
    if ($actualSize -eq $expectedSize) {
        $downloadOk = $true
        break
    }
    Log "downloaded file is $actualSize bytes, expected $expectedSize - looks truncated/corrupt$(if ($attempt -eq 1) { ', retrying download' })"
    Remove-Item -ErrorAction SilentlyContinue $tmp
}
if (-not $downloadOk) {
    Log "ERROR: download size never matched after retry - not installing, leaving yt-dlp at $currentVersion"
    exit 1
}

# Second real failure found live (on a different box): running the
# freshly-downloaded file immediately produced no output at all (empty
# string, not even an exception), while manually re-running the exact
# same file moments later worked fine. Most likely antivirus real-time
# scanning briefly locking/holding a just-written .exe before it's
# actually launchable - a well-known Windows quirk, not specific to this
# script. A short delay plus one retry absorbs it without weakening the
# actual verification (still refuses to install on a genuine mismatch).
# 2>&1 and $LASTEXITCODE are captured and logged on failure so a repeat
# of either failure mode shows the real cause instead of just "empty".
Start-Sleep -Milliseconds 750
$newVersion = "unknown"
$exitCode = "n/a"
for ($i = 0; $i -lt 2; $i++) {
    try {
        $newVersion = ((& $tmp --version 2>&1) | Out-String).Trim()
        $exitCode = $LASTEXITCODE
    } catch {
        $newVersion = "unknown"
        $exitCode = "exception: $_"
    }
    if ($newVersion -eq $tag) { break }
    if ($i -eq 0) {
        Log "version check came back '$newVersion' (exit code $exitCode), retrying once after a short delay"
        Start-Sleep -Seconds 2
    }
}

if ($newVersion -ne $tag) {
    Log "ERROR: downloaded binary ($actualSize bytes, matched expected size) reports '$newVersion' (exit code $exitCode), expected '$tag' - not installing, leaving yt-dlp at $currentVersion"
    Remove-Item -ErrorAction SilentlyContinue $tmp
    exit 1
}

Move-Item -Force $tmp $Target
Log "success: yt-dlp is now $newVersion"
exit 0
