# install.ps1 - One-line installer for codebase-memory-mcp (Windows).
#
# Usage: see README.md for install instructions.
#
# Environment:
#   CBM_DOWNLOAD_URL  Override base URL for downloads (for testing)

$ErrorActionPreference = "Stop"

# Enforce TLS 1.2+ (older PowerShell defaults to TLS 1.0 which GitHub rejects).
#
# TLS 1.3 is added ONLY where schannel can actually negotiate it: Windows 11 and
# Server 2022 (build 20348+). On Windows 10 the enum value still exists from
# .NET Framework 4.8 onward, so the assignment succeeds and nothing warns -- but
# the handshake then fails outright with "The request was aborted: Could not
# create SSL/TLS secure channel". An unsupported protocol flag in this bitmask
# is a hard failure, not a graceful downgrade, so every Windows 10 user was
# blocked at the first download (#1856). The enum-name probe additionally keeps
# the script parsing on .NET Framework 4.7, where Tls13 is not defined at all.
$CbmProtocols = [Net.SecurityProtocolType]::Tls12
if ([Environment]::OSVersion.Version.Build -ge 20348 -and
    ([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13')) {
    $CbmProtocols = $CbmProtocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $CbmProtocols
Add-Type -AssemblyName System.Net.Http

$Repo = "DeusData/codebase-memory-mcp"
$InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp"
$BinName = "codebase-memory-mcp.exe"
$WindowsArchiveNames = @(
    $BinName,
    "LICENSE",
    "install.ps1",
    "THIRD_PARTY_NOTICES.md"
)
$BaseUrl = if ($env:CBM_DOWNLOAD_URL) { $env:CBM_DOWNLOAD_URL } else { "https://github.com/$Repo/releases/latest/download" }

try { $BaseUri = [Uri]$BaseUrl } catch { $BaseUri = $null }
$AllowLoopbackHttp = (
    $BaseUri -and $BaseUri.IsAbsoluteUri -and
    $BaseUri.Scheme -eq "http" -and $BaseUri.IsLoopback -and
    [string]::IsNullOrEmpty($BaseUri.UserInfo)
)
if (-not $BaseUri -or -not $BaseUri.IsAbsoluteUri -or
    ($BaseUri.Scheme -ne "https" -and -not $AllowLoopbackHttp) -or
    -not [string]::IsNullOrEmpty($BaseUri.UserInfo)) {
    Write-Host "error: refusing non-HTTPS download URL: $BaseUrl" -ForegroundColor Red
    exit 1
}

function Invoke-CbmDownload {
    param([Parameter(Mandatory=$true)][string]$Url,
          [Parameter(Mandatory=$true)][string]$OutFile)

    $current = [Uri]$Url
    $handler = New-Object System.Net.Http.HttpClientHandler
    $handler.AllowAutoRedirect = $false
    $client = New-Object -TypeName System.Net.Http.HttpClient -ArgumentList $handler
    $client.Timeout = [TimeSpan]::FromMinutes(10)
    try {
        for ($redirects = 0; $redirects -le 5; $redirects++) {
            $allowed = $current.IsAbsoluteUri -and
                [string]::IsNullOrEmpty($current.UserInfo) -and
                ($current.Scheme -eq "https" -or
                 ($AllowLoopbackHttp -and $current.Scheme -eq "http" -and
                  $current.IsLoopback))
            if (-not $allowed) {
                throw "download redirect escaped the allowed transport: $current"
            }
            $response = $client.GetAsync(
                $current, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
            ).GetAwaiter().GetResult()
            try {
                $status = [int]$response.StatusCode
                if ($status -in @(301, 302, 303, 307, 308)) {
                    if ($redirects -eq 5 -or -not $response.Headers.Location) {
                        throw "invalid or excessive download redirect from $current"
                    }
                    $current = [Uri]::new($current, $response.Headers.Location)
                    continue
                }
                if (-not $response.IsSuccessStatusCode) {
                    throw "HTTP $status for $current"
                }
                $input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
                try {
                    $output = [System.IO.File]::Open(
                        $OutFile, [System.IO.FileMode]::Create,
                        [System.IO.FileAccess]::Write,
                        [System.IO.FileShare]::None)
                    try { $input.CopyTo($output) } finally { $output.Dispose() }
                } finally { $input.Dispose() }
                return
            } finally { $response.Dispose() }
        }
        throw "too many download redirects"
    } finally {
        $client.Dispose()
        $handler.Dispose()
    }
}

$SkipConfig = $false
foreach ($arg in $args) {
    if ($arg -eq "--skip-config") { $SkipConfig = $true }
    if ($arg -like "--dir=*") { $InstallDir = $arg.Substring(6) }
}

# Detect the OS architecture. RuntimeInformation.OSArchitecture reports the real
# OS arch (Arm64) even from an x64 process running under emulation on ARM64 --
# unlike $env:PROCESSOR_ARCHITECTURE, which reports the emulated "AMD64", and
# PROCESSOR_ARCHITEW6432, which is unset for 64-bit emulated processes. Fall back
# to the env vars only if the .NET API is somehow unavailable.
if ($env:CBM_ARCH) {
    # Explicit override wins - used by CI/tests, and an escape hatch under x64
    # emulation on ARM64 where no in-process detection is reliable.
    $Arch = $env:CBM_ARCH
} else {
    try {
        $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
        $Arch = if ($osArch -eq 'Arm64') { "arm64" } else { "amd64" }
    } catch {
        if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") {
            $Arch = "arm64"
        } else {
            $Arch = "amd64"
        }
    }
}

Write-Host "codebase-memory-mcp installer (Windows)"
Write-Host "  arch:    $Arch"
Write-Host "  target:  $InstallDir\$BinName"
Write-Host ""

# Build download URL
$Archive = "codebase-memory-mcp-windows-$Arch.zip"
$Url = "$BaseUrl/$Archive"

# Download
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-install-$(Get-Random)"
New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null

# Give the staging directory a protected owner-only DACL.
#
# Without this it inherits whatever %TEMP% carries, and the binary we are about
# to run from here validates its own directory and refuses inherited
# cross-account mutation grants. That is not a hypothetical: sandboxed clients
# leave ACEs on %TEMP% (a CodexSandboxUsers group, AppContainer SIDs, and
# orphaned SIDs from uninstalled software have all been reported), and installs
# failed with
#   activation transaction I/O failed: acl-grants-cross-account-mutation to S-1-5-21-...
# naming an ACE the installer itself inherited. See issues 1529, 1614 and 1571.
#
# cbm's own C staging already creates its directory this way; install.ps1 was
# the one path that did not, which is why redirecting TMP/TEMP worked around it.
#
# Applied after creation rather than atomically on purpose: the overload that
# takes a DirectorySecurity exists on Windows PowerShell 5.1 but not on
# PowerShell 7, and Set-Acl works on both. The directory name is unpredictable
# and nothing is written into it until the download below, so the window is not
# usefully attackable.
#
# Best-effort: a filesystem that cannot carry a DACL must not fail the install.
# If this does not take, the binary's own validation still refuses to proceed,
# which is the honest outcome rather than a silent downgrade.
try {
    $stagingAcl = New-Object System.Security.AccessControl.DirectorySecurity
    $stagingAcl.SetAccessRuleProtection($true, $false)
    $stagingOwner = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User
    $stagingAcl.SetOwner($stagingOwner)
    $stagingAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
        $stagingOwner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
    Set-Acl -Path $TmpDir -AclObject $stagingAcl -ErrorAction Stop
} catch {
    Write-Host "note: could not harden the staging directory ACL: $($_.Exception.Message)"
}

Write-Host "Downloading $Archive..."
try {
    Invoke-CbmDownload -Url $Url -OutFile "$TmpDir\$Archive"
} catch {
    Write-Host "error: download failed: $_" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
    exit 1
}


# Checksum verification is mandatory. Do not request coordinated shutdown for
# a candidate that was not positively matched to the published release digest.
$ChecksumUrl = "$BaseUrl/checksums.txt"
try {
    Invoke-CbmDownload -Url $ChecksumUrl -OutFile "$TmpDir\checksums.txt"
    $checksumPath = "$TmpDir\checksums.txt"
    if ((Get-Item -LiteralPath $checksumPath).Length -gt 1048576) {
        throw "checksums.txt exceeds the 1 MiB safety limit"
    }
    $checksumLines = @(Get-Content -LiteralPath $checksumPath | Where-Object {
        $parts = $_ -split '\s+'
        $parts.Count -ge 2 -and $parts[1].TrimStart('*') -eq $Archive
    })
    if ($checksumLines.Count -eq 0) {
        throw "no digest for $Archive in checksums.txt"
    }
    $expected = $null
    foreach ($checksumLine in $checksumLines) {
        $digest = (($checksumLine -split '\s+')[0]).ToLower()
        if ($digest -notmatch '^[0-9a-f]{64}$') {
            throw "invalid SHA-256 digest for $Archive"
        }
        if ($null -ne $expected -and $expected -ne $digest) {
            throw "conflicting SHA-256 digests for $Archive"
        }
        $expected = $digest
    }
    $actual = (Get-FileHash -Path "$TmpDir\$Archive" -Algorithm SHA256).Hash.ToLower()
    if ($expected -ne $actual) {
        throw "CHECKSUM MISMATCH (expected $expected, actual $actual)"
    }
    Write-Host "Checksum verified."
} catch {
    Write-Host "error: checksum verification failed: $_" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
    exit 1
}

# Validate the zip namespace before extraction. Windows paths are
# case-insensitive, so two entries that differ only in case are ambiguous and
# must never be allowed to overwrite each other. The official five entries are
# required at the archive root with their exact release names.
try {
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive")
    try {
        $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
        $archiveCounts = @{}
        foreach ($archiveName in $WindowsArchiveNames) { $archiveCounts[$archiveName] = 0 }
        foreach ($entry in $zip.Entries) {
            $entryName = $entry.FullName.Replace('\', '/')
            $isDirectory = $entryName.EndsWith('/')
            $pathForSegments = if ($isDirectory) { $entryName.TrimEnd('/') } else { $entryName }
            $segments = @($pathForSegments.Split('/'))
            if ([string]::IsNullOrEmpty($pathForSegments) -or
                $entryName.StartsWith('/') -or
                $entryName.Contains(':') -or
                $segments -contains '' -or
                $segments -contains '.' -or
                $segments -contains '..' -or
                @($segments | Where-Object { $_.EndsWith('.') -or $_.EndsWith(' ') }).Count -gt 0) {
                throw "unsafe zip entry path: $($entry.FullName)"
            }
            if (-not $seen.Add($pathForSegments)) {
                throw "duplicate or case-conflicting zip entry: $($entry.FullName)"
            }
            if (-not ($WindowsArchiveNames -ccontains $entryName) -or $isDirectory) {
                throw "archive contains an unexpected root entry: $($entry.FullName)"
            }
            $archiveCounts[$entryName] = $archiveCounts[$entryName] + 1
        }
        foreach ($archiveName in $WindowsArchiveNames) {
            if ($archiveCounts[$archiveName] -ne 1) {
                throw "archive must contain exactly one $archiveName"
            }
        }
        if ($seen.Count -ne $WindowsArchiveNames.Count) {
            throw "archive does not match the exact Windows release allowlist"
        }
    } finally {
        $zip.Dispose()
    }
} catch {
    Write-Host "error: unsafe or incomplete release archive: $_" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
    exit 1
}

# Extract the validated bundle. Windows ships ONE binary, exactly like Linux
# and macOS; this script is what replaces it, because a running executable
# cannot replace itself on Windows. Re-running this script IS the update.
Write-Host "Extracting..."
Expand-Archive -Path "$TmpDir\$Archive" -DestinationPath $TmpDir -Force

$DownloadedBinary = Join-Path $TmpDir $BinName
if (-not (Test-Path -LiteralPath $DownloadedBinary -PathType Leaf)) {
    Write-Host "error: $BinName not found after extraction" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir
    exit 1
}
$binaryItem = Get-Item -LiteralPath $DownloadedBinary
if ($binaryItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
    Write-Host "error: refusing reparse-point executable in release archive" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir
    exit 1
}

# Prove the downloaded binary runs before touching an existing installation.
# PS 5.1 wraps redirected native stderr into ErrorRecords, so under the global
# ErrorActionPreference=Stop a HEALTHY binary that prints one warning while
# exiting 0 becomes a terminating error here. Relax to Continue for the probe
# only; failure detection stays on $LASTEXITCODE, and the pre-seed guarantees
# a binary that fails to START (stale $LASTEXITCODE from an earlier native
# call) can never read as success.
try {
    $ProbeEap = $ErrorActionPreference
    try {
        $ErrorActionPreference = "Continue"
        $global:LASTEXITCODE = 1
        $candidateVersion = & $DownloadedBinary --version 2>&1
    } finally {
        $ErrorActionPreference = $ProbeEap
    }
    if ($LASTEXITCODE -ne 0) { throw "candidate exited with $LASTEXITCODE" }
    Write-Host "Verified candidate: $candidateVersion"
} catch {
    Write-Host "error: downloaded binary failed to run: $_" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
    exit 1
}

$Dest = Join-Path $InstallDir $BinName

# Retire the running installation before replacing it. Windows keeps an image
# lock on a running .exe: the file cannot be overwritten, but it CAN be renamed
# out of the way, which is what makes an in-place update possible from here.
if (Test-Path -LiteralPath $Dest -PathType Leaf) {
    try { & $Dest daemon stop 2>&1 | Out-Null } catch { }
    $retired = "$Dest.retired-$(Get-Date -Format yyyyMMddHHmmss)"
    $renamed = $false
    foreach ($attempt in 1..10) {
        try { Move-Item -LiteralPath $Dest -Destination $retired -Force -ErrorAction Stop; $renamed = $true; break }
        catch { Start-Sleep -Milliseconds 500 }
    }
    if (-not $renamed) {
        Write-Host "error: could not retire the existing $BinName - close all running" -ForegroundColor Red
        Write-Host "       codebase-memory-mcp sessions and coding agents, then re-run." -ForegroundColor Red
        Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
        exit 1
    }
    # A retired image stays locked until its last process exits; delete it when
    # we can, and leave it for the next run when we cannot. Never fail here.
    Remove-Item -LiteralPath $retired -Force -ErrorAction SilentlyContinue
}
Get-ChildItem -LiteralPath $InstallDir -Filter "$BinName.retired-*" -ErrorAction SilentlyContinue |
    ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }

$InstallArgs = @("install", "-y", "--force", "--dir=$InstallDir")
if ($SkipConfig) { $InstallArgs += "--skip-config" }
& $DownloadedBinary @InstallArgs
if ($LASTEXITCODE -ne 0) {
    Write-Host "error: installation failed (exit code $LASTEXITCODE)" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
    exit 1
}

# Place the installer beside the binary so `update` points at a local file
# rather than a URL, and so the next update runs THIS release's installer.
#
# Sourced from the archive we just checksum-verified, and published by rename
# rather than written over the live path. PowerShell parses a script fully
# before executing it, so self-overwrite is less hazardous here than it is for
# bash -- but rename costs nothing and keeps both platforms on one rule.
# Best effort: a failure here still leaves a working install.
$DownloadedInstaller = Join-Path $TmpDir "install.ps1"
if (Test-Path -LiteralPath $DownloadedInstaller -PathType Leaf) {
    $InstallerDest = Join-Path $InstallDir "install.ps1"
    $InstallerTmp = "$InstallerDest.new"
    try {
        Copy-Item -LiteralPath $DownloadedInstaller -Destination $InstallerTmp -Force -ErrorAction Stop
        Move-Item -LiteralPath $InstallerTmp -Destination $InstallerDest -Force -ErrorAction Stop
        Write-Host "Installed updater -> $InstallerDest"
    } catch {
        Remove-Item -LiteralPath $InstallerTmp -Force -ErrorAction SilentlyContinue
        Write-Host "note: could not place install.ps1 in $InstallDir (update will explain where to find it)"
    }
}

# Verify
# Same PS 5.1 stderr-wrapping guard as the candidate probe above; the pre-seed
# matters MOST here, because prior successful native calls leave a stale
# $LASTEXITCODE=0 that a start-failure would otherwise inherit.
try {
    $ProbeEap = $ErrorActionPreference
    try {
        $ErrorActionPreference = "Continue"
        $global:LASTEXITCODE = 1
        $ver = & $Dest --version 2>&1
    } finally {
        $ErrorActionPreference = $ProbeEap
    }
    if ($LASTEXITCODE -ne 0) { throw "installed binary exited with $LASTEXITCODE" }
    Write-Host "Installed: $ver"
} catch {
    Write-Host "error: installed binary failed to run" -ForegroundColor Red
    Remove-Item -Recurse -Force $TmpDir
    exit 1
}

# Agent configuration was included in the candidate-owned activation window.
if ($SkipConfig) {
    Write-Host ""
    Write-Host "Skipping agent configuration (--skip-config)"
}

# The verified candidate persisted the current-user PATH while holding the
# coordinated activation lease. Do not perform a second registry mutation here
# after running sessions have been allowed to restart.

# Cleanup
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue

Write-Host ""
Write-Host "Done! Restart your terminal and coding agent to start using codebase-memory-mcp."