<#
.SYNOPSIS
  Install the GameNet branch server (local-server) on the counter PC and point it
  at the cloud with the one-time token from signup.

.DESCRIPTION
  This is the counter box, not a gamer seat — provision-kiosk.ps1 is for those.
  What it does, all of it reversible with -Uninstall:

    - copies the server bundle into $InstallDir
    - writes gamenet-server.cmd holding the cloud URL + sync TOKEN, ACL'd so only
      SYSTEM and Administrators can read it (staff use this machine)
    - opens the LAN port so the seats can reach it (private/domain profiles only —
      never the public profile; this port must not face a hotel wifi)
    - registers a scheduled task that starts it as SYSTEM at boot, and starts it
    - waits for /health and prints the LAN address to put in the seats

  The token is shown ONCE at signup. Lost it? Rotate a fresh one in the dashboard
  (branch card -> «چرخش توکن») — the old one dies the moment you do.

.PARAMETER CloudToken
  The branch sync token from signup (or from «چرخش توکن» in the dashboard).

.PARAMETER CloudUrl
  Cloud API base. Default https://gamebaan.ir.

.PARAMETER SourceDir
  Folder holding the server bundle (server.mjs + migrations\ + node_modules\).
  Defaults to the folder this script sits in — i.e. unzip and run in place.

.PARAMETER InstallDir
  Where the server ends up. Default C:\GameNet\server. Its data\ subfolder holds
  gamenet.db and is NEVER touched by a re-run or by -Uninstall.

.PARAMETER Port
  LAN port the seats and the admin app connect to. Default 4700.

.PARAMETER AdminUser
  Username of the first counter login, created on a fresh database. Default «admin».

.PARAMETER AdminPassword
  Its password. Omitted = a random one is generated and printed once, here. Staff live
  in the branch database and nothing syncs them down from the cloud, so without this
  first account nobody can open the admin app at all. Change it in the app afterwards.

.PARAMETER Uninstall
  Stop + remove the task, the firewall rule and the program files. Keeps data\.

.EXAMPLE
  .\install-branch-server.ps1 -CloudUrl https://gamebaan.ir -CloudToken 9f3c...
#>
[CmdletBinding()]
param(
  [string]$CloudToken,
  [string]$CloudUrl = "https://gamebaan.ir",
  [string]$SourceDir,
  [string]$InstallDir = "C:\GameNet\server",
  [int]$Port = 4700,
  [string]$AdminUser = "admin",
  [string]$AdminPassword,
  [switch]$Uninstall
)

$ErrorActionPreference = "Stop"

# $PSScriptRoot is EMPTY inside a param() default under Windows PowerShell 5.1 when the
# script is launched with -File — it is populated for the script BODY, not for parameter
# binding. 5.1 is exactly what "Run as administrator" gives you on a café counter box, so
# the default resolved to "" there and the first Join-Path died with
# "Cannot bind argument to parameter 'Path' because it is an empty string" (hit for real
# 2026-08-06). Resolve it here instead, where the variable is populated.
if (-not $SourceDir) {
  $SourceDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
}
if (-not $SourceDir) { $SourceDir = (Get-Location).Path }

$TaskName = "GameNet Branch Server"
$RuleName = "GameNet branch server ($Port)"

function Assert-Admin {
  $id = [Security.Principal.WindowsIdentity]::GetCurrent()
  $p = New-Object Security.Principal.WindowsPrincipal($id)
  if (-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw "Run this script from an elevated (Administrator) PowerShell."
  }
}

function Remove-Install {
  try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction Stop } catch {}
  try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop } catch {}
  try { Remove-NetFirewallRule -DisplayName $RuleName -ErrorAction Stop } catch {}
}

Assert-Admin

if ($Uninstall) {
  Remove-Install
  # data\ survives on purpose: it is the café's sessions, ledger and shifts.
  Get-ChildItem -Path $InstallDir -Force -ErrorAction SilentlyContinue |
    Where-Object { $_.Name -ne "data" } |
    Remove-Item -Recurse -Force
  Write-Host "Removed. Database kept at $InstallDir\data" -ForegroundColor Yellow
  return
}

if (-not $CloudToken) { throw "-CloudToken is required (the one-time token from signup)." }
if (-not (Test-Path (Join-Path $SourceDir "server.mjs"))) {
  throw "server.mjs not found in $SourceDir — unzip the server bundle and run this script from inside it, or pass -SourceDir."
}

# Node: bundled next to the server if the zip carries one, else whatever is on PATH.
# A bundled one is not a convenience — better-sqlite3's binding in node_modules\ was
# compiled against exactly that Node's ABI and will not load on another major.
$node = Join-Path $SourceDir "node.exe"
$bundledNode = Test-Path $node
if (-not $bundledNode) {
  $cmd = Get-Command node -ErrorAction SilentlyContinue
  if (-not $cmd) { throw "node.exe not found — install Node 22 LTS (https://nodejs.org) and re-run." }
  $node = $cmd.Source
}
$major = [int]((& $node -e "process.stdout.write(String(process.versions.node.split('.')[0]))"))
if ($major -lt 20) { throw "Node $major is too old — this server needs Node 20 or newer (22 LTS recommended)." }

# A re-run is an upgrade: stop the old one before its files are replaced.
Remove-Install

New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir "data") | Out-Null

if ((Resolve-Path $SourceDir).Path -ne (Resolve-Path $InstallDir).Path) {
  Get-ChildItem -Path $SourceDir -Force |
    Where-Object { $_.Name -notin @("data", "install-branch-server.ps1") } |
    Copy-Item -Destination $InstallDir -Recurse -Force
}
# The boot task must not depend on the folder the zip was unpacked into — that is
# usually Downloads\ and it gets deleted.
if ($bundledNode) { $node = Join-Path $InstallDir "node.exe" }

# First login. Staff are branch-local (no cloud → branch staff sync), so a fresh box
# with nobody in the staff table is an admin app nobody can open. seed exits on its own
# if an account already exists, which makes a re-run an upgrade and not a reset.
$seed = Join-Path $InstallDir "bin\seed.mjs"
$seeded = $null
if (Test-Path $seed) {
  if (-not $AdminPassword) {
    $AdminPassword = -join ((48..57) + (97..122) | Get-Random -Count 12 | ForEach-Object { [char]$_ })
  }
  $env:GAMENET_DB = "$InstallDir\data\gamenet.db"
  $env:GAMENET_MIGRATIONS = "$InstallDir\migrations"
  try {
    $seeded = & $node $seed --owner-only --user $AdminUser --pass $AdminPassword 2>&1
    if ($LASTEXITCODE -ne 0) { throw "creating the first login failed:`n$seeded" }
  } finally {
    Remove-Item Env:\GAMENET_DB, Env:\GAMENET_MIGRATIONS -ErrorAction SilentlyContinue
  }
}

# The launch wrapper carries the config. A .cmd rather than task-level env vars
# because scheduled-task XML has no env block, and because a file can be ACL'd —
# the token is a credential and this machine has staff sitting at it.
$cmdPath = Join-Path $InstallDir "gamenet-server.cmd"
@"
@echo off
rem GameNet branch server — written by install-branch-server.ps1. Holds a SECRET.
set "GAMENET_PORT=$Port"
set "GAMENET_DB=$InstallDir\data\gamenet.db"
set "GAMENET_MIGRATIONS=$InstallDir\migrations"
set "GAMENET_CLOUD_URL=$CloudUrl"
set "GAMENET_CLOUD_TOKEN=$CloudToken"
"$node" "$InstallDir\server.mjs"
"@ | Set-Content -Path $cmdPath -Encoding ascii

# Break inheritance, then grant only SYSTEM + Administrators. Without the reset,
# the inherited Users:read ACE survives and every gamer account can read the token.
& icacls.exe $cmdPath /inheritance:r /grant:r "SYSTEM:(F)" "Administrators:(F)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "icacls failed on $cmdPath — the token would be world-readable; aborting." }

New-NetFirewallRule -DisplayName $RuleName -Direction Inbound -Action Allow `
  -Protocol TCP -LocalPort $Port -Profile Private, Domain | Out-Null

$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument "/c `"$cmdPath`"" -WorkingDirectory $InstallDir
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
# Restart on crash and never stop on its own: this process is the café's till.
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
  -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
  -Principal $principal -Settings $settings | Out-Null
Start-ScheduledTask -TaskName $TaskName

Write-Host "Waiting for the server to answer..." -NoNewline
$ok = $false
foreach ($i in 1..30) {
  Start-Sleep -Seconds 1
  Write-Host "." -NoNewline
  try {
    Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2 | Out-Null
    $ok = $true
    break
  } catch {}
}
Write-Host ""

if (-not $ok) {
  throw "No /health after 30s. Run `"$cmdPath`" by hand in this window to see why (it prints the real error)."
}

# The address the SEATS have to reach, so it must be the adapter that actually faces the
# LAN. "First non-loopback IPv4" is not that: a counter box with Hyper-V, WSL, VMware,
# VirtualBox or Docker installed answers with a host-only adapter (typically x.x.x.1), and
# a café that types that into its seats gets a room full of stations that cannot connect.
# Found on Aksiran's box 2026-08-06, which printed 192.168.181.1.
# Having a default gateway is what "faces the network" means; virtual adapters have none.
$lan = (Get-NetIPConfiguration |
  Where-Object { $_.IPv4DefaultGateway -and $_.NetAdapter.Status -eq "Up" } |
  Select-Object -First 1).IPv4Address.IPAddress
if (-not $lan) {
  # No default route at all (isolated LAN, no internet). Fall back to any real address and
  # say so, rather than printing nothing.
  $lan = (Get-NetIPAddress -AddressFamily IPv4 |
    Where-Object { $_.IPAddress -notlike "127.*" -and $_.IPAddress -notlike "169.254.*" } |
    Select-Object -First 1).IPAddress
  Write-Host "  (no default gateway found — verify the address below with ipconfig)" -ForegroundColor Yellow
}

Write-Host ""
Write-Host "GameNet branch server is running." -ForegroundColor Green
Write-Host "  Seats + admin app connect to:  http://${lan}:$Port"
Write-Host "  Cloud sync:                    $CloudUrl"
Write-Host "  Data (back this up):           $InstallDir\data"

if ($seeded -match "owner created") {
  Write-Host ""
  Write-Host "  Counter login (shown once — change it in the app):" -ForegroundColor Yellow
  Write-Host "    user: $AdminUser"
  Write-Host "    pass: $AdminPassword"
} elseif ($seeded) {
  Write-Host "  Counter login:                 unchanged (this box already had staff)"
}

Write-Host ""
Write-Host "Next: open the admin app, add your seats, and finish the checklist in the dashboard."
