#Requires -Version 5.1
<#
.SYNOPSIS
    NexusOS installer for Windows (native).
.DESCRIPTION
    Installs Python, Node.js, and Ollama via winget, builds the Promethean
    virtualenv and the web UI, and drops a desktop shortcut. NexusOS then runs
    as a single process: the backend on :8000 serves the built UI, and the AI
    (Ollama) is started manually from the UI.

    Run from the nexus-core directory:
        Right-click install-windows.ps1 -> "Run with PowerShell"

    Requires Windows 10/11 with winget (App Installer). No reboot needed.
.PARAMETER OwnsWindow
    Internal. Set only on the elevated run this script starts for itself. That
    run has a console of its own, so a fatal error has to pause before the window
    disappears with the message on it. In a shell the user already had open the
    text stays on screen, and a prompt there is a keystroke nobody asked for.
    Not meant to be passed by hand.
#>
param(
    [switch]$OwnsWindow
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = $PSScriptRoot

# winget draws its progress bar with U+2588/U+2592 block characters in UTF-8,
# but PowerShell decodes a native command's output using the CONSOLE codepage
# (437 on a US box). The three UTF-8 bytes of a block then render as "Gamma u e"
# - the log fills with 'GubebGubeb...' mojibake and the real messages get lost
# in it. Decoding as UTF-8 is the fix; it is per-process and does not touch the
# user's console settings.
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

# -- Helpers -------------------------------------------------------------------
function Write-Step { param([string]$Msg) Write-Host "`n==> $Msg" -ForegroundColor Cyan }
function Write-OK   { param([string]$Msg) Write-Host "    ok:   $Msg" -ForegroundColor Green }
function Write-Warn { param([string]$Msg) Write-Host "    warn: $Msg" -ForegroundColor Yellow }
function Write-Fail {
    param([string]$Msg)
    Write-Host "`n    ERROR: $Msg`n" -ForegroundColor Red
    # Only hold the window open if closing it would take the error message with
    # it. In someone else's shell the text stays on screen regardless, and a
    # prompt there is just a keystroke they did not ask for.
    if ($OwnsWindow) { Read-Host "Press Enter to exit" }
    exit 1
}

# Pull the current machine + user PATH out of the registry into this session, so
# tools winget just installed become runnable without opening a new shell.
function Update-SessionPath {
    $m = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
    $u = [System.Environment]::GetEnvironmentVariable("Path", "User")
    $env:Path = ($m, $u | Where-Object { $_ }) -join ";"
}

function Install-Winget {
    param([string]$Id, [string]$Label)
    Write-Step "Installing $Label"
    # winget animates a spinner ("-\|/") and a block progress bar using carriage
    # returns to redraw one line in place. Piping it defeats that: PowerShell
    # splits on the CRs, so every frame arrives as its own line and the log fills
    # with dozens of lone dashes and bar snapshots. Drop the frames and keep the
    # sentences. The block characters are spelled by code point because every
    # .ps1 here must stay ASCII (tests/test_smoke.py enforces it).
    $bar = "$([char]0x2588)$([char]0x2592)"
    winget install --id $Id -e --source winget `
        --accept-package-agreements --accept-source-agreements --disable-interactivity |
        Where-Object { $_ -notmatch "[$bar]" -and $_ -notmatch '^\s*[-\\|/]\s*$' -and $_.Trim() } |
        Out-Host
    # winget returns non-zero when the package is already installed / up to date;
    # that's not a failure for us. Verify presence after refreshing PATH instead.
    Update-SessionPath
    Write-OK "$Label step done"
}

# -- Self-elevate to Administrator ---------------------------------------------
$IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
    [Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $IsAdmin) {
    Write-Host "Requesting administrator privileges..." -ForegroundColor Yellow
    # -Wait -PassThru so this window blocks until the elevated install finishes
    # and can report success/failure itself - without it, this window just says
    # "Requesting..." and exits immediately, while all real progress (and the
    # "installed!" banner) happens in the separate elevated window, making the
    # original window look like it silently quit.
    $proc = Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -OwnsWindow" -Verb RunAs -Wait -PassThru
    Write-Host ""
    if ($proc.ExitCode -eq 0) {
        Write-Host "  NexusOS installed successfully." -ForegroundColor Green
        Write-Host "  Open a NEW terminal and run 'ncp help', or double-click the NexusOS desktop shortcut." -ForegroundColor White
    } else {
        Write-Host "  Install did not finish (exit code $($proc.ExitCode)). Check the elevated window for errors." -ForegroundColor Red
    }
    exit $proc.ExitCode
}

Write-Host ""
Write-Host "  NexusOS Installer for Windows (native)" -ForegroundColor White
Write-Host "  ======================================" -ForegroundColor DarkGray

# -- winget present? -----------------------------------------------------------
Write-Step "Checking winget"
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
    Write-Fail "winget not found. Install 'App Installer' from the Microsoft Store, then re-run this installer."
}
Write-OK "winget available"

# -- Install runtimes ----------------------------------------------------------
Install-Winget "Python.Python.3.12" "Python 3.12"
Install-Winget "OpenJS.NodeJS.LTS"  "Node.js LTS"
Install-Winget "Ollama.Ollama"      "Ollama"

# -- Verify tools --------------------------------------------------------------
Write-Step "Verifying tools"
foreach ($t in @("python", "npm", "ollama")) {
    if (-not (Get-Command $t -ErrorAction SilentlyContinue)) {
        Write-Fail "$t is not on PATH after install. Close this window, open a new PowerShell, and re-run the installer."
    }
    Write-OK "$t found"
}

# -- Python venv + deps --------------------------------------------------------
Write-Step "Building the Promethean virtualenv"
$Venv = Join-Path $RepoRoot "Promethean"
$VenvPy = Join-Path $Venv "Scripts\python.exe"
if (-not (Test-Path $VenvPy)) {
    python -m venv $Venv
}
if (-not (Test-Path $VenvPy)) { Write-Fail "venv creation failed at $Venv" }

& $VenvPy -m pip install --upgrade pip -q
$Req = Join-Path $RepoRoot "requirements-windows.txt"   # CPU / pure-Python overlay - right for native Windows too
if (-not (Test-Path $Req)) { Write-Fail "requirements file not found: $Req" }
& $VenvPy -m pip install -r $Req
if ($LASTEXITCODE -ne 0) { Write-Fail "pip install failed (exit $LASTEXITCODE) - see output above" }
Write-OK "Python environment ready"

# -- Frontend build ------------------------------------------------------------
Write-Step "Building the web UI"
Push-Location (Join-Path $RepoRoot "interface\web")
npm install
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fail "npm install failed (exit $LASTEXITCODE) - see output above" }
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fail "npm run build failed (exit $LASTEXITCODE) - no UI would be served" }
Pop-Location
if (-not (Test-Path (Join-Path $RepoRoot "interface\web\dist\index.html"))) {
    Write-Fail "build reported success but interface\web\dist\index.html is missing"
}
Write-OK "Web UI built (interface\web\dist)"

# -- ncp command ---------------------------------------------------------------
# management\ncp.cmd goes on the machine PATH rather than a `function ncp` in the
# PowerShell profile. Three reasons the profile route kept biting:
#   1. Execution policy defaults to Restricted, which blocks the profile itself -
#      so the function was never defined, and the .ps1 it called could not
#      have run anyway.
#   2. Profiles are a PowerShell thing. cmd, Win+R, Task Scheduler and .lnk
#      targets all reported "ncp is not recognized".
#   3. We are elevated here, so $PROFILE is the ADMIN's profile - the whole
#      -InvokerProfile dance existed to work around that. Machine PATH has no
#      such split.
# The Linux side does the matching thing: a symlink in /usr/local/bin.
Write-Step "Registering the ncp command"
try {
    $NcpCmd = Join-Path $RepoRoot "management\ncp.cmd"
    if (-not (Test-Path $NcpCmd)) { throw "ncp.cmd not found at '$NcpCmd'" }
    $BinDir = Split-Path -Parent $NcpCmd

    # Machine scope needs admin, which the self-elevation above already gave us.
    # Read-modify-write through .NET, NEVER setx: setx silently truncates PATH at
    # 1024 characters and has permanently broken machines.
    $Current = [Environment]::GetEnvironmentVariable("Path", "Machine")
    $Entries = @($Current -split ';' | Where-Object { $_ -ne "" })
    if ($Entries -contains $BinDir) {
        Write-OK "ncp already on the machine PATH ($BinDir)"
    } else {
        $New = (@($Entries) + $BinDir) -join ';'
        [Environment]::SetEnvironmentVariable("Path", $New, "Machine")
        Write-OK "ncp registered on the machine PATH ($BinDir)"
    }
    # PATH is read at process start, so this shell and any already-open one still
    # do not have it. Patch ours so the rest of the installer can call ncp.
    $env:Path = "$env:Path;$BinDir"
} catch {
    Write-Warn "Could not put ncp on PATH - add this directory to PATH by hand:"
    Write-Warn "  $RepoRoot\management"
}

# -- Legacy profile shim check --------------------------------------------------
# An earlier version of this installer registered ncp as a `function ncp` in the
# PowerShell profile instead of management\ncp.cmd (see the "why" above). Profile
# functions resolve before PATH executables, so a leftover one silently shadows
# the real ncp.cmd - `ncp` still runs, just the wrong code, with no error. We do
# not rewrite the user's profile automatically: it is their file, and a blind
# strip risks mangling whatever else lives in it alongside it. Just flag it.
Write-Step "Checking for a stale ncp() in your PowerShell profile"
$DocsRoot = [Environment]::GetFolderPath("MyDocuments")
$ProfileCandidates = @(
    (Join-Path $DocsRoot "WindowsPowerShell\Microsoft.PowerShell_profile.ps1"),
    (Join-Path $DocsRoot "PowerShell\Microsoft.PowerShell_profile.ps1")
)
$Shadowed = $ProfileCandidates | Where-Object {
    (Test-Path $_) -and (Select-String -Path $_ -Pattern '^\s*function\s+ncp\b' -Quiet)
}
if ($Shadowed) {
    Write-Warn "Found an old 'function ncp' that will shadow the real ncp.cmd:"
    foreach ($p in $Shadowed) { Write-Warn "  $p" }
    Write-Warn "  Remove that function from the file(s) above so ncp resolves to ncp.cmd."
} else {
    Write-OK "No stale ncp() in your PowerShell profile"
}

# -- Desktop shortcut ----------------------------------------------------------
Write-Step "Creating desktop shortcut"
try {
    # Target the .vbs wrapper, not powershell.exe directly: on Windows 11 with
    # Windows Terminal as the default terminal app, -WindowStyle Hidden on a
    # console-subsystem process is often ignored and a terminal tab flashes up
    # anyway. wscript.exe never allocates a console at all, sidestepping that.
    $Launcher = Join-Path $RepoRoot "bin\launch_nexus_hidden.vbs"
    $LnkPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "NexusOS.lnk"
    $ws = New-Object -ComObject WScript.Shell
    $lnk = $ws.CreateShortcut($LnkPath)
    $lnk.TargetPath = "$env:WINDIR\System32\wscript.exe"
    $lnk.Arguments = "`"$Launcher`""
    $lnk.WorkingDirectory = $RepoRoot
    $lnk.Description = "Launch NexusOS"
    $Ico = Join-Path $RepoRoot "assets\NexusOS.ico"
    if (Test-Path $Ico) { $lnk.IconLocation = "$Ico,0" }
    $lnk.Save()
    Write-OK "Desktop shortcut created"
} catch {
    Write-Warn "Could not create desktop shortcut - launch with: powershell -File launch_nexus.ps1"
}

# -- Prune Linux-only assets -----------------------------------------------------
# assets\ ships the Linux desktop theme (GTK/KDE/XFWM4, boot splash, XFCE panel
# icons) alongside NexusOS.ico, the one file Windows actually uses (the shortcut
# icon, just set above). None of the rest does anything without a GTK/KDE/XFWM4/
# Plymouth desktop under it to theme - dead weight in a Windows checkout, not a
# future-proofing keep.
Write-Step "Removing Linux-only theme assets"
try {
    $AssetsDir = Join-Path $RepoRoot "assets"
    $IcoPath = Join-Path $AssetsDir "NexusOS.ico"
    $Freed = (Get-ChildItem $AssetsDir -Recurse -Force -File -ErrorAction SilentlyContinue |
        Where-Object { $_.FullName -ne $IcoPath } |
        Measure-Object -Property Length -Sum).Sum
    Get-ChildItem $AssetsDir -Force -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -ne "NexusOS.ico" } |
        Remove-Item -Recurse -Force
    if ($Freed) {
        Write-OK "Removed $([math]::Round($Freed / 1MB, 1)) MB (Linux desktop theme - not used on Windows)"
    } else {
        Write-OK "Nothing to remove"
    }
} catch {
    Write-Warn "Could not prune assets\ - harmless, just extra disk space"
}

# -- Pull models (best-effort, LAST on purpose) --------------------------------
# This is the only multi-GB step, and the only one a user is invited to Ctrl+C.
# Ctrl+C in PowerShell 5.1 terminates the whole SCRIPT, not just the running
# native command - so with this block in the middle, skipping the download also
# skipped the PATH registration and the desktop shortcut, and left an install
# that looked finished but had no working `ncp`. Everything that makes NexusOS
# usable now runs before we get here; abort at this point and you lose only the
# models, which the Models tab can pull later.
#
# Which models ship is decided in ONE place - DEFAULT_CHAT_MODEL and
# DEFAULT_MEMORY_MODEL in synapse\nexus_config.py (rationale documented there).
# Read them instead of hardcoding, so the installer can never pull one model
# while the backend defaults to another.
Push-Location $RepoRoot
$ChatModel  = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_CHAT_MODEL as m; print(m)")
$MemModel   = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_MEMORY_MODEL as m; print(m)")
$EmbedModel = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_EMBED_MODEL as m; print(m)")
Pop-Location
if ($LASTEXITCODE -ne 0 -or -not $ChatModel -or -not $MemModel -or -not $EmbedModel) {
    Write-Fail "Could not read the default models from synapse\nexus_config.py - the venv install is broken"
}

# Models live in the project's own store on both platforms, so `ncp models list`
# and a NexusOS-spawned `ollama serve` agree on where they are. Without this the
# installer pulled into %USERPROFILE%\.ollama and NexusOS looked somewhere else.
Write-Step "Pointing Ollama at the project model store"
$ModelStore = Join-Path $RepoRoot "models"
New-Item -ItemType Directory -Path $ModelStore -Force | Out-Null
# An existing install has GBs already downloaded in the default store. Move it
# rather than make the user fetch it again; on the same volume this is instant.
$LegacyStore = Join-Path $env:USERPROFILE ".ollama\models"
if ((Test-Path (Join-Path $LegacyStore "blobs")) -and
    -not (Test-Path (Join-Path $ModelStore "blobs"))) {
    Write-Host "    Moving models from $LegacyStore (no re-download)..." -ForegroundColor DarkGray
    try {
        foreach ($sub in @("blobs", "manifests")) {
            $src = Join-Path $LegacyStore $sub
            if (Test-Path $src) { Move-Item -Path $src -Destination (Join-Path $ModelStore $sub) -Force }
        }
        Write-OK "Existing models moved into $ModelStore"
    } catch {
        Write-Warn "Could not move the old model store - the pull below will refetch."
    }
}
$env:OLLAMA_MODELS = $ModelStore
Write-OK "Model store: $ModelStore"

# Pin it as the default chat model. Runs BEFORE the download because it only
# writes a settings row - nothing here needs the model to be present on disk,
# and putting it after the pull meant declining the download also lost it. Runs from the repo root so the synapse
# package imports; only writes the 'model' setting in the shared DB.
Write-Step "Setting $ChatModel as the default model"
Push-Location $RepoRoot
& $VenvPy -c "from synapse.memory.store import store; from synapse.nexus_config import DEFAULT_CHAT_MODEL; store.update_settings({'model': DEFAULT_CHAT_MODEL})"
$seedOk = ($LASTEXITCODE -eq 0)
Pop-Location
if ($seedOk) { Write-OK "Default model set to $ChatModel" }
else { Write-Warn "Could not persist default model - pick it at the top of the chat instead." }

# No auto-download: the right models depend on the machine (a 4GB GPU can't fit
# an 8B model). The Models tab detects VRAM/RAM and marks which models fit, so
# the user pulls the right ones there instead of us guessing several GB.
Write-Step "Skipping model download (pick hardware-appropriate models in the app)"
Write-Host "    No models were downloaded. Open NexusOS -> Models. The Required tab" -ForegroundColor DarkGray
Write-Host "    lists the two models NexusOS itself depends on and gates the rest" -ForegroundColor DarkGray
Write-Host "    of the catalog until both are installed:" -ForegroundColor DarkGray
Write-Host "      - $MemModel    (required - memory curator: extracts facts, titles chats)" -ForegroundColor Gray
Write-Host "      - $EmbedModel  (required - semantic recall of past conversations)" -ForegroundColor Gray
Write-Host "    After that, the Recommended tab detects your VRAM/RAM and flags which" -ForegroundColor DarkGray
Write-Host "    chat models fit (green = GPU, yellow = CPU/RAM), e.g. $ChatModel." -ForegroundColor DarkGray

# -- Make Ollama manual-start (NexusOS owns the lifecycle) ----------------------
Write-Step "Setting Ollama to manual start"
# The Ollama desktop app autostarts a server at every login, and the elevated
# 'ollama pull' above leaves an elevated server the user-level app cannot stop -
# which makes the Start/Stop AI button get stuck. Remove the login autostart and
# stop the running server so NexusOS controls Ollama via its Start AI button.
try {
    $ollamaAutostart = Join-Path ([Environment]::GetFolderPath("Startup")) "Ollama.lnk"
    if (Test-Path $ollamaAutostart) {
        Remove-Item $ollamaAutostart -Force
        Write-OK "Removed Ollama login autostart"
    }
    # Get-Process, not taskkill: taskkill writes "ERROR: The process ... not
    # found." to stderr when nothing is running, and with $ErrorActionPreference
    # = Stop PowerShell 5.1 promotes a native command's stderr to a TERMINATING
    # error. `2>$null` does not prevent that. So the ordinary case - Ollama
    # simply is not running - aborted this whole block and printed the warning
    # below, claiming the install could not configure something it had in fact
    # already done. Cmdlets have no such trap.
    foreach ($name in @("ollama app", "ollama")) {
        Get-Process -Name $name -ErrorAction SilentlyContinue |
            Stop-Process -Force -ErrorAction SilentlyContinue
    }
    Write-OK "Ollama set to manual start"
} catch {
    Write-Warn "Could not adjust Ollama autostart - you can still Start/Stop AI from the app."
}

# -- Done ----------------------------------------------------------------------
Write-Host ""
Write-Host "  ==========================================================" -ForegroundColor Green
Write-Host "  NexusOS is installed!" -ForegroundColor Green
Write-Host ""
Write-Host "  Launch:  double-click the NexusOS shortcut on your desktop" -ForegroundColor White
Write-Host "           (or run  powershell -File launch_nexus.ps1)" -ForegroundColor White
Write-Host ""
Write-Host "  The app opens at http://localhost:8000" -ForegroundColor White
Write-Host "  The AI starts OFF - click 'Start AI' in the sidebar to turn it on." -ForegroundColor White
Write-Host ""
Write-Host "  Terminal:  open a NEW terminal (PowerShell or cmd), then run  ncp help" -ForegroundColor White
Write-Host "  ==========================================================" -ForegroundColor Green
Write-Host ""
Write-Host "  This window has to close for ncp to work." -ForegroundColor Yellow
Write-Host "  A process reads PATH once, when it starts. This shell started before" -ForegroundColor DarkGray
Write-Host "  the installer put management\ on PATH, so it will never see ncp no" -ForegroundColor DarkGray
Write-Host "  matter what you run here. The next terminal you open will." -ForegroundColor DarkGray
Write-Host ""

# Skip the wait entirely when stdin is redirected: nobody is at the keyboard to
# press anything, and both ReadKey and Read-Host were confirmed (empirically,
# not just in theory) to hang indefinitely in that case instead of failing
# fast - Read-Host only fails fast under an explicit -NonInteractive flag,
# which is not how the self-elevation Start-Process above launches this script.
if (-not [Console]::IsInputRedirected) {
    Write-Host "  Press any key to close this window..." -ForegroundColor Yellow
    try { $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } catch { Read-Host | Out-Null }
}

# $OwnsWindow means this is the elevated instance the self-elevation block above
# launched with `-File`, so a clean `exit` closes it - and does so with exit code
# 0, which is what lets Windows Terminal's closeOnExit actually close the tab
# instead of treating an abrupt kill as a crash and falling back to a fresh
# shell in the pane. Without $OwnsWindow we are in a shell the user already had
# open (they ran an admin PowerShell and invoked the script directly, so it was
# never re-launched with -File) - `exit` there would just return to the prompt
# in a session whose PATH is permanently stale, so kill the host instead; the
# point is the user cannot accidentally keep using a shell where ncp never
# resolves.
# Caveat: a Start-Transcript in this window never reaches Stop-Transcript in the
# Stop-Process path. The transcript is flushed as it is written, so the content
# survives without the closing footer.
if ($OwnsWindow) {
    exit 0
} else {
    Stop-Process -Id $PID
}
