UI4ME.Core.psm1

# UI4ME Core - PowerShell binding for the versioned native host protocol.
#
# Supported: PowerShell 7.2+ on Linux and Windows, Windows PowerShell 5.1.
# The Rust host owns windows, input, layout, rendering and accessibility. This
# module owns the backend side of the private, authenticated JSON-RPC channel.
#
# Threading: a .NET reader thread parses frames into a queue. Every user
# scriptblock runs on the caller's PowerShell runspace while it pumps the
# queue (Wait-UI4ME, or any RPC that waits for its reply). Notifications are
# delivered one at a time in host order; while a callback runs, nested pumps
# still answer replies and host lifecycle requests but defer further
# notifications until the callback returns.
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$script:UI4MEVersion = '1.0.0'
$script:UI4MEHelperLoaded = $false
$script:UI4MEIsWindows = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
$script:MaximumDeferred = 128
if ($PSVersionTable.PSEdition -eq 'Core' -and $PSVersionTable.PSVersion -lt [version]'7.2') {
    throw "UI4ME.Core requires PowerShell 7.2 or later (or Windows PowerShell 5.1); this is $($PSVersionTable.PSVersion)"
}

# ---------------------------------------------------------------- internals --

function Initialize-UI4MEHelper {
    if ($script:UI4MEHelperLoaded) { return }
    $existing = 'UI4ME.Transport' -as [type]
    if ($null -eq $existing) {
        $source = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'private/UI4ME.Transport.cs'), [Text.Encoding]::UTF8)
        if ($PSVersionTable.PSEdition -ne 'Core') { $source = "#define UI4ME_NETFX`n" + $source }
        Add-Type -TypeDefinition $source -Language CSharp -ErrorAction Stop
        $existing = 'UI4ME.Transport' -as [type]
    }
    if ($null -eq $existing -or $null -eq $existing.GetMethod('Register') -or $null -eq ('UI4ME.MappedBitmap' -as [type])) {
        throw 'An incompatible UI4ME helper is already loaded in this process. Start a new PowerShell process.'
    }
    $script:UI4MEHelperLoaded = $true
}

function Write-UI4MELog {
    param([string]$Message)
    try { [Console]::Error.WriteLine("UI4ME: $Message"); [Console]::Error.Flush() } catch { $null = $_ }  # logging must never fail the app
}

function Format-UI4MEError {
    param($ErrorRecord)
    $text = "$($ErrorRecord.Exception.GetType().Name): $($ErrorRecord.Exception.Message)"
    $position = $ErrorRecord.InvocationInfo
    if ($null -ne $position -and $position.ScriptName) {
        $text += " ($($position.ScriptName):$($position.ScriptLineNumber))"
    }
    return $text
}

function Get-UI4MEField {
    param($Object, [string]$Name, $Default = $null)
    if ($null -eq $Object) { return $Default }
    if ($Object -is [System.Collections.IDictionary]) {
        if ($Object.Contains($Name)) { return $Object[$Name] }
        return $Default
    }
    $p = $Object.PSObject.Properties[$Name]
    if ($null -ne $p) { return $p.Value }
    return $Default
}

function New-UI4MEStaleError {
    param([string]$Message)
    Initialize-UI4MEHelper
    return (New-Object -TypeName 'UI4ME.StaleObjectException' -ArgumentList $Message)
}

function Test-UI4METype {
    param($Value, [string]$TypeName)
    return ($null -ne $Value -and $Value -is [psobject] -and $Value.PSObject.TypeNames.Count -gt 0 -and
        $Value.PSObject.TypeNames[0] -eq $TypeName)
}

function Assert-UI4MEApplication {
    param($Application)
    if (-not (Test-UI4METype $Application 'UI4ME.Application')) { throw 'Expected a UI4ME.Application from Connect-UI4ME' }
    if ($Application.Closed) { throw (New-UI4MEStaleError 'The UI4ME application connection was closed') }
    if ($Application.Disconnected) {
        throw (New-Object -TypeName 'UI4ME.DisconnectedException' -ArgumentList $Application.Transport.Failure)
    }
}
function Assert-UI4MEWindow {
    param($Window)
    if (-not (Test-UI4METype $Window 'UI4ME.Window')) { throw 'Expected a UI4ME.Window from New-UI4MEWindow' }
    Assert-UI4MEApplication $Window.Application
    if ($Window.Closed) { throw (New-UI4MEStaleError "UI4ME window $($Window.Id) is closed") }
}
function Assert-UI4MEDocument {
    param($Document)
    if (-not (Test-UI4METype $Document 'UI4ME.Document')) { throw 'Expected a UI4ME.Document from Open-UI4MEFile or Open-UI4MEHtml' }
    Assert-UI4MEWindow $Document.Window
    if ($Document.Retired -or $null -eq $Document.Window.Document -or
        -not [object]::ReferenceEquals($Document.Window.Document, $Document)) {
        throw (New-UI4MEStaleError 'This UI4ME document was replaced, unloaded, or its window closed')
    }
}
function Assert-UI4MENode {
    param($Node)
    if (-not (Test-UI4METype $Node 'UI4ME.Node')) { throw 'Expected a UI4ME.Node from Get-UI4MENode' }
    Assert-UI4MEDocument $Node.Document
}
function Get-UI4MEDocumentIds {
    param($Document)
    Assert-UI4MEDocument $Document
    return @{ window_id = $Document.Window.Id; document_id = $Document.Id }
}

# Adds element/node targeting for commands that accept either.
function Add-UI4METarget {
    param([hashtable]$Parameters, $Document, [string]$Element, $Node, [switch]$Required,
          [string]$ElementKey = 'element', [string]$NodeKey = 'node')
    if ($Element -and $null -ne $Node) { throw 'Use -Element or -Node, not both' }
    if ($null -ne $Node) {
        Assert-UI4MENode $Node
        if (-not [object]::ReferenceEquals($Node.Document, $Document)) { throw 'The node belongs to another document' }
        $Parameters[$NodeKey] = $Node.Id
    } elseif ($Element) {
        $Parameters[$ElementKey] = $Element
    } elseif ($Required) {
        throw 'An -Element id or a -Node is required'
    }
}

function Set-UI4MEDisconnected {
    param($Application)
    if ($Application.Disconnected) { return }
    $Application.Disconnected = $true
    foreach ($request in @($Application.Pending.Values)) { $request.Failed = $true }
    foreach ($window in @($Application.Windows.Values)) {
        $window.Closed = $true
        if ($null -ne $window.Document) { Remove-UI4MEDocumentState $window.Document }
    }
    $Application.Windows.Clear()
}

function Invoke-UI4MECallback {
    # Runs one user scriptblock; returns its output as an array.
    param($Application, [scriptblock]$Callback, [object[]]$Arguments)
    if ($Application.Depth -ge $Application.MaxDepth) { throw 'UI4ME callback nesting limit (32) exceeded' }
    $Application.Depth++
    $wasDispatching = $Application.Dispatching
    $Application.Dispatching = $true
    try { return ,@(& $Callback @Arguments) }
    finally {
        $Application.Dispatching = $wasDispatching
        $Application.Depth--
    }
}

function Invoke-UI4MEDocumentHandlers {
    param($Application, $Document, $EventObject)
    if ($null -eq $EventObject) { return }
    foreach ($pair in @(@('window_id', $Document.Window.Id), @('document_id', $Document.Id))) {
        if ($null -eq $EventObject.PSObject.Properties[$pair[0]]) {
            $EventObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty($pair[0], $pair[1])))
        }
    }
    $kind = Get-UI4MEField $EventObject 'type'
    $target = Get-UI4MEField $EventObject 'element'
    $route = @(Get-UI4MEField $EventObject 'path' @())
    foreach ($entry in @($Document.Handlers.ToArray())) {
        if ($Document.Retired) { break }
        if ($entry.Type -ne $kind) { continue }
        if (-not $entry.Element -or $entry.Element -eq $target -or $route -contains $entry.Element) {
            try { $null = Invoke-UI4MECallback $Application $entry.Callback @($EventObject) }
            catch { Write-UI4MELog "event handler for '$kind' failed: $(Format-UI4MEError $_)" }
        }
    }
}

function Invoke-UI4MENotification {
    param($Application, $Message)
    $method = $Message.Method
    $params = $Message.Params
    switch -Exact ($method) {
        'app.shutdown' { $Application.ShutdownRequested = $true }
        'window.event' {
            $windowId = Get-UI4MEField $params 'window_id'
            if ($windowId -and $Application.Windows.ContainsKey($windowId) -and
                (Get-UI4MEField $params 'type') -eq 'closed') {
                $win = $Application.Windows[$windowId]
                $win.Closed = $true
                if ($null -ne $win.Document) { Remove-UI4MEDocumentState $win.Document }
                [void]$Application.Windows.Remove($windowId)
            }
        }
        { $_ -eq 'document.event' -or $_ -eq 'document.error' } {
            $windowId = Get-UI4MEField $params 'window_id'
            if (-not $windowId -or -not $Application.Windows.ContainsKey($windowId)) { break }
            $win = $Application.Windows[$windowId]
            $doc = $win.Document
            if ($win.Closed -or $null -eq $doc -or $doc.Retired -or $doc.Id -ne (Get-UI4MEField $params 'document_id')) { break }
            if ($method -eq 'document.event') {
                $evt = Get-UI4MEField $params 'event'
                if ($null -ne $evt -and $evt -is [psobject]) { Invoke-UI4MEDocumentHandlers $Application $doc $evt }
            } else {
                $text = [string](Get-UI4MEField $params 'message' 'Document operation failed')
                if ($text.Length -gt 4096) { $text = $text.Substring(0, 4096) }
                $fatal = [bool](Get-UI4MEField $params 'fatal' $false)
                Write-UI4MELog "document error ($($win.Id), $($doc.Id)): $text"
                try {
                    Invoke-UI4MEDocumentHandlers $Application $doc ([pscustomobject]@{ type = 'error'; message = $text; fatal = $fatal })
                } finally {
                    if ($fatal) {
                        # The host already unloaded the failed renderer. An error
                        # handler may have loaded a replacement; keep that one.
                        Remove-UI4MEDocumentState $doc
                        if ([object]::ReferenceEquals($win.Document, $doc)) { $win.Document = $null }
                    }
                }
            }
        }
    }
    if ($Application.Notifications.ContainsKey($method)) {
        try { $null = Invoke-UI4MECallback $Application $Application.Notifications[$method] @($params) }
        catch { Write-UI4MELog "notification handler for '$method' failed: $(Format-UI4MEError $_)" }
    }
}

function ConvertTo-UI4MEDecision {
    param([object[]]$Output)
    if ($Output.Count -eq 0) { return $false }
    return [bool]$Output[$Output.Count - 1]
}

function Invoke-UI4MEHostRequest {
    param($Application, $Message)
    $params = $Message.Params
    $result = $null
    $failure = $null
    try {
        switch -Exact ($Message.Method) {
            'window.before_close' {
                $id = Get-UI4MEField $params 'window_id'
                $callback = $null
                if ($id -and $Application.BeforeClose.ContainsKey($id)) { $callback = $Application.BeforeClose[$id] }
                if ($null -eq $callback) { $result = @{ allow = $true } }
                else { $result = @{ allow = (ConvertTo-UI4MEDecision (Invoke-UI4MECallback $Application $callback @($params))) } }
            }
            'app.before_quit' {
                if ($null -eq $Application.BeforeQuit) { $result = @{ allow = $true } }
                else { $result = @{ allow = (ConvertTo-UI4MEDecision (Invoke-UI4MECallback $Application $Application.BeforeQuit @($params))) } }
            }
            'app.shutdown' { $Application.ShutdownRequested = $true; $result = @{ ok = $true } }
            default { $failure = @{ code = -32601; message = "Unknown method: $($Message.Method)" } }
        }
    } catch {
        Write-UI4MELog "host request '$($Message.Method)' callback failed: $(Format-UI4MEError $_)"
        $failure = @{ code = -32603; message = "Callback failed: $($_.Exception.GetType().Name)" }
    }
    $envelope = @{ jsonrpc = '2.0'; id = $Message.Id }
    if ($null -ne $failure) { $envelope.error = $failure } else { $envelope.result = $result }
    try { $Application.Transport.Send($envelope) }
    catch { Set-UI4MEDisconnected $Application }
}

# Processes at most one queued message. Returns $true when one was handled.
function Invoke-UI4MEPump {
    param($Application, [int]$TimeoutMilliseconds)
    if (-not $Application.Dispatching -and $Application.Deferred.Count -gt 0) {
        Invoke-UI4MENotification $Application $Application.Deferred.Dequeue()
        return $true
    }
    if ($Application.Disconnected) {
        throw (New-Object -TypeName 'UI4ME.DisconnectedException' -ArgumentList $Application.Transport.Failure)
    }
    try { $message = $Application.Transport.Receive($TimeoutMilliseconds) }
    catch {
        Set-UI4MEDisconnected $Application
        throw (New-Object -TypeName 'UI4ME.DisconnectedException' -ArgumentList $Application.Transport.Failure)
    }
    if ($null -eq $message) { return $false }
    switch -Exact ($message.Kind) {
        'response' {
            if ($Application.Pending.ContainsKey($message.Id)) { $Application.Replies[$message.Id] = $message }
        }
        'request' { Invoke-UI4MEHostRequest $Application $message }
        default {
            if ($message.Method -eq 'app.shutdown') { $Application.ShutdownRequested = $true }
            if ($Application.Dispatching) {
                if ($Application.Deferred.Count -ge $script:MaximumDeferred) { $Application.DroppedNotifications++ }
                else { $Application.Deferred.Enqueue($message) }
            } else {
                Invoke-UI4MENotification $Application $message
            }
        }
    }
    return $true
}

# ------------------------------------------------------------ connection/RPC --

function Connect-UI4ME {
    <#
    .SYNOPSIS
    Connects this PowerShell backend to the native UI4ME host that launched it.
    .DESCRIPTION
    Reads UI4ME_ENDPOINT and UI4ME_TOKEN, opens the private Unix socket or Windows
    named pipe, and authenticates with host.hello (protocol version 1).
    #>

    [CmdletBinding()]
    param(
        [string]$Endpoint = $env:UI4ME_ENDPOINT,
        [string]$Token = $env:UI4ME_TOKEN,
        [ValidateRange(1, 120000)][int]$ConnectTimeoutMilliseconds = 15000
    )
    if ([string]::IsNullOrWhiteSpace($Endpoint) -or [string]::IsNullOrWhiteSpace($Token)) {
        throw 'UI4ME_ENDPOINT and UI4ME_TOKEN come from the native host. Start the app with Start-UI4MEHost or ui4me-host run --app app.toml.'
    }
    Initialize-UI4MEHelper
    $transport = New-Object -TypeName 'UI4ME.Transport' -ArgumentList @($Endpoint, $ConnectTimeoutMilliseconds)
    $app = [pscustomobject]@{
        Transport = $transport
        Hello = $null
        Windows = @{}
        Replies = @{}
        Pending = @{}
        Deferred = (New-Object 'System.Collections.Generic.Queue[object]')
        NextId = [long]0
        Depth = 0
        MaxDepth = 32
        Dispatching = $false
        Closed = $false
        Disconnected = $false
        ShutdownRequested = $false
        Ready = $false
        BeforeQuit = $null
        BeforeClose = @{}
        Notifications = @{}
        DroppedNotifications = [long]0
    }
    $app.PSObject.TypeNames.Insert(0, 'UI4ME.Application')
    try {
        $app.Hello = Invoke-UI4MERpc -Application $app -Method 'host.hello' -Parameters @{
            token = $Token; protocol_version = 1
            client = @{ name = 'ui4me-powershell'; version = $script:UI4MEVersion }
        }
        return $app
    } catch {
        $app.Closed = $true
        $transport.Dispose()
        throw
    }
}

function Start-UI4MERpc {
    <# .SYNOPSIS Sends a host request without waiting; returns a UI4ME.Request for Wait-UI4MERpc or Stop-UI4MERpc. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application, [Parameter(Mandatory)][string]$Method,
          [System.Collections.IDictionary]$Parameters = @{})
    Assert-UI4MEApplication $Application
    $Application.NextId++
    $id = 'ps-' + $Application.NextId.ToString([Globalization.CultureInfo]::InvariantCulture)
    $request = [pscustomobject]@{ Application = $Application; Id = $id; Method = $Method
        Completed = $false; Cancelled = $false; Failed = $false }
    $request.PSObject.TypeNames.Insert(0, 'UI4ME.Request')
    $Application.Transport.Register($id)
    $Application.Pending[$id] = $request
    try {
        $Application.Transport.Send(@{ jsonrpc = '2.0'; id = $id; method = $Method; params = $Parameters })
    } catch {
        [void]$Application.Pending.Remove($id)
        $Application.Transport.Forget($id)
        throw
    }
    return $request
}

function Stop-UI4MERpc {
    <# .SYNOPSIS Requests cancellation (for example closes a native dialog). Not a rollback. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Request)
    if (-not (Test-UI4METype $Request 'UI4ME.Request')) { throw 'Expected a UI4ME.Request from Start-UI4MERpc' }
    if ($Request.Completed -or $Request.Cancelled) { return }
    $app = $Request.Application
    $Request.Cancelled = $true
    [void]$app.Pending.Remove($Request.Id)
    [void]$app.Replies.Remove($Request.Id)
    $app.Transport.Forget($Request.Id)
    if (-not $app.Closed -and -not $app.Disconnected) {
        try { $app.Transport.Send(@{ jsonrpc = '2.0'; method = 'request.cancel'; params = @{ id = $Request.Id } }) }
        catch { Set-UI4MEDisconnected $app }
    }
}

function Wait-UI4MERpc {
    <#
    .SYNOPSIS
    Waits for a request's result while pumping events. -1 waits indefinitely.
    .DESCRIPTION
    On timeout the request is cancelled and UI4ME.RpcTimeoutException is thrown;
    the native outcome is then unknown. Host errors throw UI4ME.RpcException
    with Code, RpcMessage and RpcData.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Request, [ValidateRange(-1, 2147483647)][int]$TimeoutMilliseconds = 15000)
    if (-not (Test-UI4METype $Request 'UI4ME.Request')) { throw 'Expected a UI4ME.Request from Start-UI4MERpc' }
    $app = $Request.Application
    $clock = [Diagnostics.Stopwatch]::StartNew()
    while ($true) {
        if ($Request.Cancelled) { throw (New-UI4MEStaleError "UI4ME request $($Request.Method) was cancelled") }
        if ($Request.Completed) { throw 'This UI4ME request was already awaited' }
        if ($app.Replies.ContainsKey($Request.Id)) {
            $message = $app.Replies[$Request.Id]
            [void]$app.Replies.Remove($Request.Id)
            [void]$app.Pending.Remove($Request.Id)
            $Request.Completed = $true
            if ($message.IsError) {
                throw (New-Object -TypeName 'UI4ME.RpcException' -ArgumentList @(
                    $Request.Method, $message.ErrorCode, $message.ErrorMessage, $message.ErrorData))
            }
            return $message.Result
        }
        if ($Request.Failed -or $app.Closed -or $app.Disconnected) {
            [void]$app.Pending.Remove($Request.Id)
            $reason = if ($app.Closed) { 'the application connection was closed' } else { $app.Transport.Failure }
            throw (New-Object -TypeName 'UI4ME.DisconnectedException' -ArgumentList "UI4ME $($Request.Method) did not complete: $reason")
        }
        $interval = 250
        if ($TimeoutMilliseconds -ge 0) {
            $remaining = $TimeoutMilliseconds - $clock.ElapsedMilliseconds
            if ($remaining -le 0) {
                Stop-UI4MERpc $Request
                throw (New-Object -TypeName 'UI4ME.RpcTimeoutException' -ArgumentList "UI4ME $($Request.Method) timed out after $TimeoutMilliseconds ms. Its outcome is unknown; cancellation was requested.")
            }
            $interval = [int][Math]::Min(250, $remaining)
        }
        [void](Invoke-UI4MEPump $app $interval)
    }
}

function Invoke-UI4MERpc {
    <#
    .SYNOPSIS
    Calls a native host JSON-RPC method and returns its result.
    .DESCRIPTION
    Events and lifecycle requests keep being processed while waiting. The default
    timeout is 15 seconds, or unlimited for dialog.* methods (user operated).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application, [Parameter(Mandatory)][string]$Method,
          [System.Collections.IDictionary]$Parameters = @{}, [ValidateRange(-1, 2147483647)][int]$TimeoutMilliseconds = 15000)
    if (-not $PSBoundParameters.ContainsKey('TimeoutMilliseconds') -and $Method.StartsWith('dialog.')) { $TimeoutMilliseconds = -1 }
    $request = Start-UI4MERpc -Application $Application -Method $Method -Parameters $Parameters
    return (Wait-UI4MERpc -Request $request -TimeoutMilliseconds $TimeoutMilliseconds)
}

function Send-UI4MENotification {
    <# .SYNOPSIS Sends a JSON-RPC notification (no reply) to the host. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application, [Parameter(Mandatory)][string]$Method,
          [System.Collections.IDictionary]$Parameters = @{})
    Assert-UI4MEApplication $Application
    $Application.Transport.Send(@{ jsonrpc = '2.0'; method = $Method; params = $Parameters })
}

function Wait-UI4ME {
    <#
    .SYNOPSIS
    Pumps host events and runs callbacks on this PowerShell runspace.
    .DESCRIPTION
    Waits up to -TimeoutMilliseconds for the first message, then drains up to 64
    queued messages and returns how many were handled. -UntilShutdown pumps until
    the host requests shutdown and throws UI4ME.DisconnectedException if the host
    disappears first. -Once handles at most one message.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Pump')]
    param([Parameter(Mandatory)]$Application,
          [Parameter(ParameterSetName = 'Pump')][ValidateRange(-1, 2147483647)][int]$TimeoutMilliseconds = 100,
          [Parameter(ParameterSetName = 'Pump')][switch]$Once,
          [Parameter(ParameterSetName = 'Run')][switch]$UntilShutdown)
    if (-not (Test-UI4METype $Application 'UI4ME.Application')) { throw 'Expected a UI4ME.Application from Connect-UI4ME' }
    if ($Application.Closed) { return 0 }
    if ($UntilShutdown) {
        while (-not $Application.ShutdownRequested) {
            try { [void](Invoke-UI4MEPump $Application 1000) }
            catch [UI4ME.DisconnectedException] {
                if ($Application.ShutdownRequested) { break }
                throw
            }
        }
        return 0
    }
    $processed = 0
    try {
        if (Invoke-UI4MEPump $Application $TimeoutMilliseconds) {
            $processed++
            while (-not $Once -and $processed -lt 64 -and (Invoke-UI4MEPump $Application 0)) { $processed++ }
        }
    } catch [UI4ME.DisconnectedException] {
        if (-not $Application.ShutdownRequested) { throw }
    }
    return $processed
}

function Close-UI4ME {
    <# .SYNOPSIS Closes the host connection and retires every window, document and bitmap handle. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowNull()]$Application)
    if ($null -eq $Application -or -not (Test-UI4METype $Application 'UI4ME.Application') -or $Application.Closed) { return }
    $Application.Closed = $true
    foreach ($win in @($Application.Windows.Values)) {
        $win.Closed = $true
        if ($null -ne $win.Document) { Remove-UI4MEDocumentState $win.Document }
    }
    $Application.Windows.Clear(); $Application.Pending.Clear(); $Application.Replies.Clear(); $Application.Deferred.Clear()
    $Application.Transport.Dispose()
}

# ------------------------------------------------------------------ windows --

$script:WindowFields = [ordered]@{
    Title = 'title'; Width = 'width'; Height = 'height'; Visible = 'visible'; Resizable = 'resizable'
    Decorations = 'decorations'; Minimized = 'minimized'; Maximized = 'maximized'; Fullscreen = 'fullscreen'
    Focus = 'focus'; X = 'x'; Y = 'y'; MinWidth = 'min_width'; MinHeight = 'min_height'
    MaxWidth = 'max_width'; MaxHeight = 'max_height'
}

function New-UI4MEWindow {
    <# .SYNOPSIS Creates a native window. Load content with Open-UI4MEFile or Open-UI4MEHtml. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application, [string]$Title = '',
          [ValidateRange(1, 8192)][int]$Width = 900, [ValidateRange(1, 8192)][int]$Height = 600,
          [bool]$Visible = $true, [bool]$Resizable = $true, [bool]$Decorations = $true,
          [ValidateRange(1, 8192)][int]$MinWidth, [ValidateRange(1, 8192)][int]$MinHeight,
          [ValidateRange(1, 8192)][int]$MaxWidth, [ValidateRange(1, 8192)][int]$MaxHeight)
    Assert-UI4MEApplication $Application
    $p = @{ title = $Title; width = $Width; height = $Height; visible = $Visible; resizable = $Resizable; decorations = $Decorations }
    foreach ($key in @('MinWidth', 'MinHeight', 'MaxWidth', 'MaxHeight')) {
        if ($PSBoundParameters.ContainsKey($key)) { $p[$script:WindowFields[$key]] = $PSBoundParameters[$key] }
    }
    $res = Invoke-UI4MERpc -Application $Application -Method 'window.create' -Parameters $p
    $id = [string](Get-UI4MEField $res 'window_id')
    if ([string]::IsNullOrEmpty($id)) { throw 'The host did not return a window_id' }
    $win = [pscustomobject]@{ Application = $Application; Id = $id; Document = $null; Closed = $false }
    $win.PSObject.TypeNames.Insert(0, 'UI4ME.Window')
    $Application.Windows[$id] = $win
    return $win
}

function Set-UI4MEWindow {
    <#
    .SYNOPSIS
    Changes native window state: title, size, position, minimized/maximized/fullscreen, focus, constraints.
    .DESCRIPTION
    Pass typed parameters or a -Properties hashtable of host field names. Size constraints
    are set in pairs; pass $null in -Properties (e.g. @{min_width=$null;min_height=$null}) to clear.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Window, [System.Collections.IDictionary]$Properties = @{},
          [string]$Title, [ValidateRange(1, 8192)][int]$Width, [ValidateRange(1, 8192)][int]$Height,
          [bool]$Visible, [bool]$Resizable, [bool]$Decorations, [bool]$Minimized, [bool]$Maximized,
          [bool]$Fullscreen, [bool]$Focus, [int]$X, [int]$Y,
          [ValidateRange(1, 8192)][int]$MinWidth, [ValidateRange(1, 8192)][int]$MinHeight,
          [ValidateRange(1, 8192)][int]$MaxWidth, [ValidateRange(1, 8192)][int]$MaxHeight)
    Assert-UI4MEWindow $Window
    $p = @{}
    foreach ($key in $Properties.Keys) { $p[[string]$key] = $Properties[$key] }
    foreach ($key in $script:WindowFields.Keys) {
        if ($PSBoundParameters.ContainsKey($key)) { $p[$script:WindowFields[$key]] = $PSBoundParameters[$key] }
    }
    $p.window_id = $Window.Id
    return (Invoke-UI4MERpc -Application $Window.Application -Method 'window.set' -Parameters $p)
}

function Get-UI4MEWindow {
    <# .SYNOPSIS Returns the host's description of a native window. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Window)
    Assert-UI4MEWindow $Window
    return (Invoke-UI4MERpc -Application $Window.Application -Method 'window.get' -Parameters @{ window_id = $Window.Id })
}

function Get-UI4MEWindowList {
    <# .SYNOPSIS Lists the host's open windows of this application. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Application)
    return ,@(Invoke-UI4MERpc -Application $Application -Method 'window.list')
}

function Close-UI4MEWindow {
    <# .SYNOPSIS Requests a graceful close; Set-UI4MEBeforeClose policy may veto it. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Window)
    Assert-UI4MEWindow $Window
    $null = Invoke-UI4MERpc -Application $Window.Application -Method 'window.request_close' -Parameters @{ window_id = $Window.Id }
}

function Request-UI4MEAttention {
    <# .SYNOPSIS Asks the desktop to flag the window (informational or critical). #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Window,
        [ValidateSet('informational', 'critical', 'none')][string]$Kind = 'informational')
    Assert-UI4MEWindow $Window
    $value = if ($Kind -eq 'none') { $null } else { $Kind }
    return (Invoke-UI4MERpc -Application $Window.Application -Method 'window.request_attention' -Parameters @{ window_id = $Window.Id; kind = $value })
}

function Set-UI4MEBeforeClose {
    <# .SYNOPSIS Sets the window close policy. The scriptblock's last output decides (true = allow). #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Window, [Parameter(Mandatory)][AllowNull()][scriptblock]$Callback)
    Assert-UI4MEWindow $Window
    if ($null -eq $Callback) { [void]$Window.Application.BeforeClose.Remove($Window.Id) }
    else { $Window.Application.BeforeClose[$Window.Id] = $Callback }
}

function Set-UI4MEBeforeQuit {
    <# .SYNOPSIS Sets the application quit policy. The scriptblock's last output decides (true = allow). #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application, [Parameter(Mandatory)][AllowNull()][scriptblock]$Callback)
    Assert-UI4MEApplication $Application
    $Application.BeforeQuit = $Callback
}

function Set-UI4MEReady {
    <# .SYNOPSIS Tells the host that startup finished. Requires at least one window. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Application)
    Assert-UI4MEApplication $Application
    if (-not $Application.Ready) {
        $null = Invoke-UI4MERpc -Application $Application -Method 'app.ready'
        $Application.Ready = $true
    }
}

function Request-UI4MEQuit {
    <# .SYNOPSIS Requests an orderly application quit; Set-UI4MEBeforeQuit policy may veto it. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Application)
    return (Invoke-UI4MERpc -Application $Application -Method 'app.request_quit')
}

function Register-UI4MENotification {
    <#
    .SYNOPSIS
    Runs a scriptblock for a host notification method, e.g. desktop.preferences_changed or window.event.
    .DESCRIPTION
    One callback per method; pass $null to remove it. Built-in window and document
    bookkeeping always runs first.
    #>

    [CmdletBinding()] param([Parameter(Mandatory)]$Application, [Parameter(Mandatory)][string]$Method,
        [Parameter(Mandatory)][AllowNull()][scriptblock]$Callback)
    Assert-UI4MEApplication $Application
    if ($null -eq $Callback) { [void]$Application.Notifications.Remove($Method) }
    else { $Application.Notifications[$Method] = $Callback }
}

# ---------------------------------------------------------------- documents --

function New-UI4MEDocumentObject {
    param($Window, $Result)
    $id = [string](Get-UI4MEField $Result 'document_id')
    if ([string]::IsNullOrEmpty($id)) { throw 'The host did not return a document_id' }
    $doc = [pscustomobject]@{ Window = $Window; Id = $id; Retired = $false
        Handlers = (New-Object System.Collections.ArrayList); Bitmaps = (New-Object System.Collections.ArrayList) }
    $doc.PSObject.TypeNames.Insert(0, 'UI4ME.Document')
    # Retire the previous facade only after the atomic replacement succeeded.
    if ($null -ne $Window.Document) { Remove-UI4MEDocumentState $Window.Document }
    $Window.Document = $doc
    return $doc
}

function Remove-UI4MEDocumentState {
    param($Document)
    if ($null -eq $Document -or $Document.Retired) { return }
    $Document.Retired = $true
    $Document.Handlers.Clear()
    foreach ($bitmap in @($Document.Bitmaps.ToArray())) { Remove-UI4MELiveBitmap -Bitmap $bitmap -Quiet }
}

function Open-UI4MEHtml {
    <#
    .SYNOPSIS
    Loads generated HTML into a window, atomically replacing its document.
    .DESCRIPTION
    -AssetRoot names the folder that relative stylesheet, image and font URLs resolve against.
    The previous document object, its handlers, nodes and bitmaps become stale.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Window, [Parameter(Mandatory)][AllowEmptyString()][string]$Html, [string]$AssetRoot)
    Assert-UI4MEWindow $Window
    $p = @{ window_id = $Window.Id; html = $Html }
    if ($AssetRoot) { $p.asset_root = Resolve-UI4MEPath $AssetRoot }
    $res = Invoke-UI4MERpc -Application $Window.Application -Method 'document.load' -Parameters $p -TimeoutMilliseconds 30000
    return (New-UI4MEDocumentObject $Window $res)
}

function Open-UI4MEFile {
    <# .SYNOPSIS Loads an HTML file into a window, atomically replacing its document. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Window, [Parameter(Mandatory)][string]$Path, [string]$AssetRoot)
    Assert-UI4MEWindow $Window
    $p = @{ window_id = $Window.Id; path = (Resolve-UI4MEPath $Path) }
    if ($AssetRoot) { $p.asset_root = Resolve-UI4MEPath $AssetRoot }
    $res = Invoke-UI4MERpc -Application $Window.Application -Method 'document.load' -Parameters $p -TimeoutMilliseconds 30000
    return (New-UI4MEDocumentObject $Window $res)
}

function Resolve-UI4MEPath {
    # Resolves against the PowerShell location (not the process directory), keeping non-ASCII names intact.
    param([string]$Path)
    return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
}

function Remove-UI4MEDocument {
    <# .SYNOPSIS Unloads the window's document; the window stays open and empty. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document)
    $ids = Get-UI4MEDocumentIds $Document
    $null = Invoke-UI4MERpc -Application $Document.Window.Application -Method 'document.unload' -Parameters $ids
    Remove-UI4MEDocumentState $Document
    if ([object]::ReferenceEquals($Document.Window.Document, $Document)) { $Document.Window.Document = $null }
}

function Register-UI4MEDocumentEvent {
    <#
    .SYNOPSIS
    Subscribes a scriptblock to document events (click, input, change, keydown, contextmenu, auxclick, submit, ...).
    .DESCRIPTION
    -Element delegates to an element id on the event's route. The callback receives the event
    object (type, element, path, plus window_id and document_id) after native dispatch. Inside a
    function, pass ({ ... }.GetNewClosure()) to capture local variables.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Type,
          [Parameter(Mandatory)][scriptblock]$Callback, [string]$Element)
    Assert-UI4MEDocument $Document
    if ($Document.Handlers.Count -ge 256) { throw 'Document callback limit (256) reached' }
    $sub = [pscustomobject]@{ Document = $Document; Type = $Type; Element = $Element; Callback = $Callback }
    $sub.PSObject.TypeNames.Insert(0, 'UI4ME.Subscription')
    [void]$Document.Handlers.Add($sub)
    return $sub
}

function Unregister-UI4MEDocumentEvent {
    <# .SYNOPSIS Removes a subscription returned by Register-UI4MEDocumentEvent. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Subscription)
    if (-not (Test-UI4METype $Subscription 'UI4ME.Subscription')) { throw 'Expected a UI4ME.Subscription' }
    [void]$Subscription.Document.Handlers.Remove($Subscription)
}

function Invoke-UI4MEDocumentCommand {
    <#
    .SYNOPSIS
    Runs any document command from the Core protocol (docs/PROTOCOL_DOCUMENTS.md) and returns its result.
    .EXAMPLE
    Invoke-UI4MEDocumentCommand -Document $doc -Operation get_html -Parameters @{ element = 'status'; outer = $true }
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Operation,
          [System.Collections.IDictionary]$Parameters = @{})
    $p = Get-UI4MEDocumentIds $Document
    $cmd = @{ op = $Operation }
    foreach ($key in $Parameters.Keys) {
        if ($key -eq 'op') { throw 'The operation is given by -Operation' }
        $cmd[[string]$key] = $Parameters[$key]
    }
    $p.command = $cmd
    return (Invoke-UI4MERpc -Application $Document.Window.Application -Method 'document.command' -Parameters $p)
}

function Invoke-UI4MEBatch {
    <# .SYNOPSIS Applies several id-addressed DOM operations as one atomic transaction. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][object[]]$Operations)
    $null = Invoke-UI4MEDocumentCommand -Document $Document -Operation 'batch' -Parameters @{ ops = $Operations }
}

function Get-UI4MEDocumentText {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element)
    return [string](Get-UI4MEField (Invoke-UI4MEDocumentCommand $Document 'get_text' @{ element = $Element }) 'text')
}
function Set-UI4MEDocumentText {
    <# .SYNOPSIS Replaces an element's content with text (never parsed as HTML). #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
          [Parameter(Mandatory)][AllowEmptyString()][string]$Text)
    $null = Invoke-UI4MEDocumentCommand $Document 'set_text' @{ element = $Element; text = $Text }
}
function Get-UI4MEValue {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element)
    return [string](Get-UI4MEField (Invoke-UI4MEDocumentCommand $Document 'get_value' @{ element = $Element }) 'value')
}
function Set-UI4MEDocumentValue {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
          [Parameter(Mandatory)][AllowEmptyString()][string]$Value)
    $null = Invoke-UI4MEDocumentCommand $Document 'set_value' @{ element = $Element; value = $Value }
}
function Get-UI4MEChecked {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element)
    return [bool](Get-UI4MEField (Invoke-UI4MEDocumentCommand $Document 'get_checked' @{ element = $Element }) 'checked')
}
function Set-UI4MEChecked {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
        [Parameter(Mandatory)][bool]$Checked)
    $null = Invoke-UI4MEDocumentCommand $Document 'set_checked' @{ element = $Element; checked = $Checked }
}
function Set-UI4MEDocumentAttribute {
    <# .SYNOPSIS Sets or (-Remove) removes an attribute. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
          [Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value = '', [switch]$Remove)
    if ($Remove) { $null = Invoke-UI4MEDocumentCommand $Document 'remove_attribute' @{ element = $Element; name = $Name } }
    else { $null = Invoke-UI4MEDocumentCommand $Document 'set_attribute' @{ element = $Element; name = $Name; value = $Value } }
}
function Set-UI4MEClass {
    <# .SYNOPSIS Adds a class to an element, or removes it with -Remove. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
          [Parameter(Mandatory)][string]$Name, [switch]$Remove)
    $op = if ($Remove) { 'remove_class' } else { 'add_class' }
    $null = Invoke-UI4MEDocumentCommand $Document $op @{ element = $Element; name = $Name }
}
function Set-UI4MEDocumentHtml {
    <# .SYNOPSIS Replaces (default) or inserts trusted HTML. Use Set-UI4MEDocumentText for untrusted text. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
          [Parameter(Mandatory)][AllowEmptyString()][string]$Html,
          [ValidateSet('replace', 'beforebegin', 'afterbegin', 'beforeend', 'afterend')][string]$Position = 'replace')
    if ($Position -eq 'replace') {
        $null = Invoke-UI4MEDocumentCommand $Document 'replace_children_html' @{ element = $Element; html = $Html }
    } else {
        $null = Invoke-UI4MEDocumentCommand $Document 'insert_html' @{ element = $Element; html = $Html; position = $Position }
    }
}
function Remove-UI4MEElement {
    <# .SYNOPSIS Removes an element (and its subtree) by id. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element)
    $null = Invoke-UI4MEDocumentCommand $Document 'remove_element' @{ element = $Element }
}
function Find-UI4MEElement {
    <# .SYNOPSIS Runs a CSS selector query; returns paginated element ids. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Selector,
        [ValidateRange(0, 2147483647)][int]$Offset = 0, [ValidateRange(1, 4096)][int]$Limit = 1024)
    return (Invoke-UI4MEDocumentCommand $Document 'query' @{ selector = $Selector; offset = $Offset; limit = $Limit })
}
function Add-UI4MEStylesheet {
    <# .SYNOPSIS Adds a stylesheet; returns its sheet handle for Set-UI4MEStylesheet. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][AllowEmptyString()][string]$Css)
    return (Invoke-UI4MEDocumentCommand $Document 'add_stylesheet' @{ css = $Css })
}
function Get-UI4MEStylesheets {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document)
    return ,@(Get-UI4MEField (Invoke-UI4MEDocumentCommand $Document 'list_stylesheets') 'sheets' @())
}
function Set-UI4MEStylesheet {
    <# .SYNOPSIS Replaces the CSS of a sheet, or removes it with -Remove. #>
    [CmdletBinding(DefaultParameterSetName = 'Replace')]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][long]$Sheet,
        [Parameter(Mandatory, ParameterSetName = 'Replace')][AllowEmptyString()][string]$Css,
        [Parameter(Mandatory, ParameterSetName = 'Remove')][switch]$Remove)
    if ($Remove) { $null = Invoke-UI4MEDocumentCommand $Document 'remove_stylesheet' @{ sheet = $Sheet }; return }
    return (Invoke-UI4MEDocumentCommand $Document 'replace_stylesheet' @{ sheet = $Sheet; css = $Css })
}
function Get-UI4MEComputedStyle {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string[]]$Properties,
        [string]$Element, $Node)
    $p = @{ properties = $Properties }
    Add-UI4METarget $p $Document $Element $Node
    return (Invoke-UI4MEDocumentCommand $Document 'get_computed_style' $p)
}
function Get-UI4MEEnvironment {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document)
    return (Invoke-UI4MEDocumentCommand $Document 'get_environment')
}
function Set-UI4MEEnvironment {
    <# .SYNOPSIS Overrides prefers-color-scheme / prefers-reduced-motion for this document. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$ColorScheme, [string]$ReducedMotion)
    $p = @{}
    if ($PSBoundParameters.ContainsKey('ColorScheme')) { $p.color_scheme = $ColorScheme }
    if ($PSBoundParameters.ContainsKey('ReducedMotion')) { $p.reduced_motion = $ReducedMotion }
    return (Invoke-UI4MEDocumentCommand $Document 'set_environment' $p)
}
function Get-UI4MEScroll {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Element)
    $p = @{}
    if ($Element) { $p.element = $Element }
    return (Invoke-UI4MEDocumentCommand $Document 'get_scroll' $p)
}
function Set-UI4MEScroll {
    <# .SYNOPSIS Scrolls the document or an element to X/Y (CSS px). #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Element, [double]$X, [double]$Y,
        [ValidateSet('auto', 'instant', 'smooth')][string]$Behavior = 'auto')
    $p = @{ behavior = $Behavior }
    if ($Element) { $p.element = $Element }
    if ($PSBoundParameters.ContainsKey('X')) { $p.x = $X }
    if ($PSBoundParameters.ContainsKey('Y')) { $p.y = $Y }
    return (Invoke-UI4MEDocumentCommand $Document 'scroll_to' $p)
}
function Get-UI4MEFocusedElement {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document)
    return (Invoke-UI4MEDocumentCommand $Document 'get_focused_element')
}
function Set-UI4MEFocus {
    <# .SYNOPSIS Focuses an element or node, or with -Blur removes focus. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Element, $Node, [switch]$Blur)
    $p = @{}
    Add-UI4METarget $p $Document $Element $Node -Required:(-not $Blur)
    $op = if ($Blur) { 'blur' } else { 'focus' }
    $null = Invoke-UI4MEDocumentCommand $Document $op $p
}
function Get-UI4MESelection {
    <# .SYNOPSIS Returns a text control's selection (UTF-16 offsets). #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Element, $Node)
    $p = @{}
    Add-UI4METarget $p $Document $Element $Node -Required
    return (Invoke-UI4MEDocumentCommand $Document 'get_control_selection' $p)
}
function Set-UI4MESelection {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Element, $Node,
        [Parameter(Mandatory)][ValidateRange(0, 2147483647)][int]$Start, [Parameter(Mandatory)][ValidateRange(0, 2147483647)][int]$End,
        [ValidateSet('none', 'forward', 'backward')][string]$Direction = 'none')
    $p = @{ start = $Start; end = $End; direction = $Direction }
    Add-UI4METarget $p $Document $Element $Node -Required
    return (Invoke-UI4MEDocumentCommand $Document 'set_control_selection' $p)
}
function Invoke-UI4MEEditCommand {
    <#
    .SYNOPSIS
    Runs an editing command (undo, redo, cut, copy, paste, selectAll, delete, ...) through the native editor.
    .DESCRIPTION
    Clipboard commands complete asynchronously; observe input/error events. Acceptance does not
    mean the OS transfer succeeded.
    #>

    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Command,
        [string]$Element, $Node)
    $p = @{ command = $Command }
    Add-UI4METarget $p $Document $Element $Node
    return (Invoke-UI4MEDocumentCommand $Document 'edit_command' $p)
}
function Get-UI4MEFormData {
    <# .SYNOPSIS Returns ordered form entries (repeated names and file metadata preserved). #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Form, $FormNode, [string]$Submitter, $SubmitterNode)
    $p = @{}
    Add-UI4METarget $p $Document $Form $FormNode -Required
    Add-UI4METarget $p $Document $Submitter $SubmitterNode -ElementKey 'submitter' -NodeKey 'submitter_node'
    return (Invoke-UI4MEDocumentCommand $Document 'get_form_data' $p)
}
function Submit-UI4MEForm {
    <# .SYNOPSIS Requests form submission (validation, then a submit event). #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Form, $FormNode, [string]$Submitter, $SubmitterNode)
    $p = @{}
    Add-UI4METarget $p $Document $Form $FormNode -Required
    Add-UI4METarget $p $Document $Submitter $SubmitterNode -ElementKey 'submitter' -NodeKey 'submitter_node'
    return (Invoke-UI4MEDocumentCommand $Document 'request_submit' $p)
}
function Reset-UI4MEForm {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Form, $FormNode)
    $p = @{}
    Add-UI4METarget $p $Document $Form $FormNode -Required
    return (Invoke-UI4MEDocumentCommand $Document 'reset_form' $p)
}
function Get-UI4MESelectOptions {
    <# .SYNOPSIS Returns a select element's options (value, label, selected, disabled). #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element)
    return ,@(Get-UI4MEField (Invoke-UI4MEDocumentCommand $Document 'get_options' @{ element = $Element }) 'options' @())
}
function Set-UI4MESelectedValues {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Values)
    $null = Invoke-UI4MEDocumentCommand $Document 'set_selected_values' @{ element = $Element; values = $Values }
}
function Show-UI4MEPopover {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
        [ValidateSet('show', 'hide', 'toggle')][string]$Action = 'show')
    $op = switch ($Action) { 'show' { 'show_popover' } 'hide' { 'hide_popover' } 'toggle' { 'toggle_popover' } }
    $res = Invoke-UI4MEDocumentCommand $Document $op @{ element = $Element }
    if ($Action -eq 'toggle') { return [bool](Get-UI4MEField $res 'open') }
}
function Show-UI4MEHtmlDialog {
    <# .SYNOPSIS Shows an HTML <dialog> (modal by default) or closes it with -Close. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
        [switch]$Close, [bool]$Modal = $true, [AllowEmptyString()][string]$ReturnValue = '')
    if ($Close) { $null = Invoke-UI4MEDocumentCommand $Document 'close_dialog' @{ element = $Element; return_value = $ReturnValue }; return }
    $null = Invoke-UI4MEDocumentCommand $Document 'show_dialog' @{ element = $Element; modal = $Modal }
}
function Set-UI4MEPreventDefault {
    <# .SYNOPSIS Registers, before input happens, whether the native default action of an event is prevented. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Element,
        [Parameter(Mandatory)][Alias('Event')][string]$EventType, [bool]$Prevent = $true, [string]$Key,
        [bool]$Shift, [bool]$Ctrl, [bool]$Alt, [bool]$Meta)
    $p = @{ element = $Element; event = $EventType; prevent = $Prevent }
    if ($PSBoundParameters.ContainsKey('Key')) { $p.key = $Key }
    foreach ($m in @('Shift', 'Ctrl', 'Alt', 'Meta')) {
        if ($PSBoundParameters.ContainsKey($m)) { $p[$m.ToLowerInvariant()] = $PSBoundParameters[$m] }
    }
    $null = Invoke-UI4MEDocumentCommand $Document 'set_default_action' $p
}
function Get-UI4MEDiagnostics {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document)
    return (Invoke-UI4MEDocumentCommand $Document 'get_diagnostics')
}
function Get-UI4MEResourceStats {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document)
    return (Invoke-UI4MEDocumentCommand $Document 'get_resource_stats')
}
function Clear-UI4MEUnusedResources {
    [CmdletBinding()] param([Parameter(Mandatory)]$Document, [string]$Url)
    $p = @{}
    if ($Url) { $p.url = $Url }
    return (Invoke-UI4MEDocumentCommand $Document 'purge_unused_resources' $p)
}

function ConvertTo-UI4MEBytes {
    # PowerShell unrolls byte[] returned from functions into object[]; accept that.
    param($Value, [string]$What)
    if ($Value -is [byte[]]) { return ,$Value }
    if ($Value -is [System.Collections.IList]) {
        try { return ,([byte[]]$Value) } catch { throw "$What must contain only byte values 0-255" }
    }
    throw "$What must be a byte[]"
}

function Register-UI4MEResources {
    <#
    .SYNOPSIS
    Atomically replaces one document-local ui4me-res://NAME/ bundle.
    .DESCRIPTION
    -Entries maps relative paths to byte[] or @{ Bytes = [byte[]]; Mime = 'image/png' }.
    At most 256 entries and 1 MiB decoded per registration.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Name,
          [Parameter(Mandatory)][System.Collections.IDictionary]$Entries)
    Assert-UI4MEDocument $Document
    if ($Entries.Count -gt 256) { throw 'A resource registration permits at most 256 entries' }
    $size = 0
    $items = New-Object System.Collections.ArrayList
    foreach ($key in $Entries.Keys) {
        $entry = $Entries[$key]
        $mime = 'application/octet-stream'
        if ($entry -is [System.Collections.IDictionary]) {
            $bytes = Get-UI4MEField $entry 'Bytes'; $mime = [string](Get-UI4MEField $entry 'Mime' $mime)
        } else { $bytes = $entry }
        $bytes = ConvertTo-UI4MEBytes $bytes "Resource '$key'"
        if ($mime -notmatch '^[^,\r\n]+$') { throw "Resource '$key' has an invalid MIME type" }
        $size += $bytes.Length
        if ($size -gt 1048576) { throw 'A resource registration permits at most 1 MiB of decoded bytes' }
        [void]$items.Add(@{ path = [string]$key; data_url = ('data:' + $mime + ';base64,' + [Convert]::ToBase64String($bytes)) })
    }
    return (Invoke-UI4MEDocumentCommand $Document 'register_resource_bundle' @{ name = $Name; entries = $items.ToArray() })
}
function Unregister-UI4MEResources {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Name)
    return (Invoke-UI4MEDocumentCommand $Document 'unregister_resource_bundle' @{ name = $Name })
}

# ------------------------------------------------------------------- nodes --

function Get-UI4MENode {
    <# .SYNOPSIS Resolves an element id (or the root) to a generation-bound node, or wraps a host reference. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [string]$Element, [string]$Reference)
    Assert-UI4MEDocument $Document
    if ($Element -and $Reference) { throw 'Use -Element or -Reference, not both' }
    if ($Reference) { $ref = $Reference } else {
        $p = @{}
        if ($Element) { $p.element = $Element }
        $ref = [string](Get-UI4MEField (Invoke-UI4MEDocumentCommand $Document 'node_of' $p) 'node')
    }
    if (-not $ref) { throw 'The host did not return a node reference' }
    $node = [pscustomobject]@{ Document = $Document; Id = $ref }
    $node.PSObject.TypeNames.Insert(0, 'UI4ME.Node')
    return $node
}
function Get-UI4MENodes {
    <# .SYNOPSIS Paginated node references for a selector, including elements without ids. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Selector,
        [ValidateRange(0, 2147483647)][int]$Offset = 0, [ValidateRange(1, 4096)][int]$Limit = 1024)
    return (Invoke-UI4MEDocumentCommand $Document 'query_nodes' @{ selector = $Selector; offset = $Offset; limit = $Limit })
}
function Get-UI4MENodeInfo {
    [CmdletBinding()] param([Parameter(Mandatory)]$Node)
    Assert-UI4MENode $Node
    return (Invoke-UI4MEDocumentCommand $Node.Document 'get_node' @{ node = $Node.Id })
}
function Get-UI4MENodeChildren {
    [CmdletBinding()] param([Parameter(Mandatory)]$Node,
        [ValidateRange(0, 2147483647)][int]$Offset = 0, [ValidateRange(1, 4096)][int]$Limit = 256)
    Assert-UI4MENode $Node
    return (Invoke-UI4MEDocumentCommand $Node.Document 'node_children' @{ node = $Node.Id; offset = $Offset; limit = $Limit })
}
function Invoke-UI4MENodeBatch {
    <# .SYNOPSIS Applies node-addressed operations (@{op=...;node=...}) as one atomic transaction. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][object[]]$Operations)
    $null = Invoke-UI4MEDocumentCommand -Document $Document -Operation 'node_batch' -Parameters @{ ops = $Operations }
}
function Invoke-UI4MENodeOperation {
    <#
    .SYNOPSIS
    Applies one node operation: set_text, append_text, set_attribute, remove_attribute, add_class,
    remove_class, insert_html, replace_children_html, trim_children, move_element, remove_element.
    #>

    [CmdletBinding()] param([Parameter(Mandatory)]$Node, [Parameter(Mandatory)][string]$Operation,
        [System.Collections.IDictionary]$Parameters = @{})
    Assert-UI4MENode $Node
    $p = @{ op = $Operation; node = $Node.Id }
    foreach ($key in $Parameters.Keys) {
        if ($key -in @('op', 'node')) { throw "Reserved node operation key: $key" }
        $value = $Parameters[$key]
        if (Test-UI4METype $value 'UI4ME.Node') {
            Assert-UI4MENode $value
            if (-not [object]::ReferenceEquals($value.Document, $Node.Document)) { throw 'Cannot move nodes between documents' }
            $value = $value.Id
        }
        $p[[string]$key] = $value
    }
    $null = Invoke-UI4MEDocumentCommand $Node.Document 'node_batch' @{ ops = @($p) }
}

# ----------------------------------------------------------- desktop services --

function Get-UI4MECapabilities {
    [CmdletBinding()] param([Parameter(Mandatory)]$Application)
    return (Invoke-UI4MERpc -Application $Application -Method 'host.capabilities')
}
function Get-UI4MEPreferences {
    <# .SYNOPSIS Observed desktop preferences (color scheme, contrast, motion, ...) and their source. #>
    [CmdletBinding()] param([Parameter(Mandatory)]$Application)
    return (Invoke-UI4MERpc -Application $Application -Method 'desktop.preferences')
}
function Get-UI4MEClipboard {
    [CmdletBinding()] param([Parameter(Mandatory)]$Application)
    return [string](Get-UI4MEField (Invoke-UI4MERpc -Application $Application -Method 'clipboard.read_text') 'text')
}
function Set-UI4MEClipboard {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application, [Parameter(Mandatory)][AllowEmptyString()][string]$Text)
    $null = Invoke-UI4MERpc -Application $Application -Method 'clipboard.write_text' -Parameters @{ text = $Text }
}
function Open-UI4MEExternal {
    <#
    .SYNOPSIS
    Opens a URL or document with the desktop's default handler, or with -Reveal shows a file in the file manager.
    .DESCRIPTION
    Host policy refuses executables, scripts and shortcuts as open targets.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Application, [Parameter(Mandatory)][string]$Target, $Window, [switch]$Reveal)
    Assert-UI4MEApplication $Application
    $p = @{}
    if ($Reveal) { $p.path = Resolve-UI4MEPath $Target; $method = 'desktop.reveal_file' }
    else { $p.target = $Target; $method = 'desktop.open_external' }
    if ($null -ne $Window) {
        Assert-UI4MEWindow $Window
        if (-not [object]::ReferenceEquals($Window.Application, $Application)) { throw 'The window belongs to another application' }
        $p.window_id = $Window.Id
    }
    return (Invoke-UI4MERpc -Application $Application -Method $method -Parameters $p -TimeoutMilliseconds 60000)
}
function Show-UI4MEFileDialog {
    <#
    .SYNOPSIS
    Shows a native open/save/folder dialog. Returns the host result (paths), waiting without a deadline.
    .DESCRIPTION
    -NoWait returns a UI4ME.Request; Wait-UI4MERpc awaits it and Stop-UI4MERpc closes the dialog.
    -Filters takes @(@{name='Images';extensions=@('png','jpg')}).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Window,
        [ValidateSet('OpenFiles', 'SaveFile', 'ChooseFolder')][string]$Kind = 'OpenFiles',
        [string]$Title, [string]$Directory, [string]$FileName, [object[]]$Filters = @(), [bool]$Multiple = $false,
        [switch]$NoWait)
    Assert-UI4MEWindow $Window
    $method = switch ($Kind) { 'OpenFiles' { 'dialog.open_files' } 'SaveFile' { 'dialog.save_file' } 'ChooseFolder' { 'dialog.choose_folder' } }
    $p = @{ window_id = $Window.Id }
    if ($PSBoundParameters.ContainsKey('Title')) { $p.title = $Title }
    if ($PSBoundParameters.ContainsKey('Directory')) { $p.directory = Resolve-UI4MEPath $Directory }
    if ($PSBoundParameters.ContainsKey('FileName')) {
        if ($Kind -ne 'SaveFile') { throw '-FileName applies to -Kind SaveFile' }
        $p.file_name = $FileName
    }
    if ($Kind -eq 'OpenFiles') { $p.multiple = $Multiple }
    if ($Kind -ne 'ChooseFolder') { $p.filters = $Filters }
    if ($NoWait) { return (Start-UI4MERpc -Application $Window.Application -Method $method -Parameters $p) }
    return (Invoke-UI4MERpc -Application $Window.Application -Method $method -Parameters $p -TimeoutMilliseconds -1)
}

# ------------------------------------------------------------ live bitmaps --

function New-UI4MELiveBitmap {
    <#
    .SYNOPSIS
    Creates a bounded RGBA8 live image for <img src="ui4me-bitmap://ID">, shared through a host-created file mapping.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Document, [Parameter(Mandatory)][string]$Id,
        [Parameter(Mandatory)][ValidateRange(1, 4096)][int]$Width, [Parameter(Mandatory)][ValidateRange(1, 4096)][int]$Height,
        [ValidateSet(2, 3)][int]$Slots = 2)
    Assert-UI4MEDocument $Document
    if ($Id -cnotmatch '^[A-Za-z0-9_-]{1,64}$') { throw 'Bitmap ID must be 1-64 ASCII letters, digits, _ or -' }
    $app = $Document.Window.Application
    $ids = @{ window_id = $Document.Window.Id; document_id = $Document.Id; bitmap_id = $Id }
    $p = @{ window_id = $Document.Window.Id; document_id = $Document.Id; bitmap_id = $Id
        width = $Width; height = $Height; slots = $Slots; pixel_format = 'RGBA8' }
    $res = Invoke-UI4MERpc -Application $app -Method 'bitmap.create' -Parameters $p
    $bytes = [long]$Width * [long]$Height * 4
    $stride = [long](Get-UI4MEField $res 'stride' 0)
    $slotBytes = [long](Get-UI4MEField $res 'slot_bytes' 0)
    $slotCount = [int](Get-UI4MEField $res 'slots' 0)
    $mapping = $null
    try {
        if ($stride -ne [long]$Width * 4 -or $slotBytes -ne $bytes -or $slotCount -ne $Slots) {
            throw 'The host returned invalid bitmap geometry'
        }
        $mapping = New-Object -TypeName 'UI4ME.MappedBitmap' -ArgumentList @(
            [string](Get-UI4MEField $res 'path'), [long]($bytes * $Slots), [bool]$script:UI4MEIsWindows)
    } catch {
        # A successful bitmap.create owns host resources even if mapping fails.
        try { $null = Invoke-UI4MERpc -Application $app -Method 'bitmap.close' -Parameters $ids } catch { $null = $_ }  # best effort; the original error is rethrown
        throw
    }
    $bitmap = [pscustomobject]@{ Document = $Document; Id = $Id; Width = $Width; Height = $Height
        Stride = [int]$stride; SlotBytes = $slotBytes; Slots = $Slots; Sequence = [long]0
        Closed = $false; Busy = $false; Mapping = $mapping }
    $bitmap.PSObject.TypeNames.Insert(0, 'UI4ME.LiveBitmap')
    [void]$Document.Bitmaps.Add($bitmap)
    return $bitmap
}

function Send-UI4MELiveBitmap {
    <#
    .SYNOPSIS
    Writes one complete straight-alpha RGBA8 frame into the next slot and presents it.
    .DESCRIPTION
    Returns after the host has snapshotted the slot (synchronous backpressure). With -Try,
    returns $null instead of re-entering while this bitmap's previous present is in progress
    (for example from a callback that runs during that present).
    #>

    [CmdletBinding()]
    # -Pixels is deliberately untyped: Windows PowerShell 5.1 inspects every element when a
    # mandatory parameter is typed [byte[]] (about 150 ms for a 1024x768 frame).
    param([Parameter(Mandatory)]$Bitmap, [Parameter(Mandatory)]$Pixels, [switch]$Try)
    if (-not (Test-UI4METype $Bitmap 'UI4ME.LiveBitmap')) { throw 'Expected a UI4ME.LiveBitmap from New-UI4MELiveBitmap' }
    if ($Pixels -isnot [byte[]]) { $Pixels = ConvertTo-UI4MEBytes $Pixels 'Pixels' }
    if ($Pixels.LongLength -ne $Bitmap.SlotBytes) { throw "Expected $($Bitmap.SlotBytes) RGBA8 bytes, received $($Pixels.LongLength)" }
    if ($Bitmap.Busy) {
        if ($Try) { return $null }
        throw 'A present of this bitmap is already in progress (use -Try from callbacks)'
    }
    if ($Bitmap.Closed) { throw (New-UI4MEStaleError "Live bitmap $($Bitmap.Id) is closed") }
    Assert-UI4MEDocument $Bitmap.Document
    $Bitmap.Busy = $true
    try {
        $Bitmap.Sequence++
        $slot = [int]($Bitmap.Sequence % $Bitmap.Slots)
        $Bitmap.Mapping.Write([long]$slot * $Bitmap.SlotBytes, $Pixels)
        $doc = $Bitmap.Document
        return (Invoke-UI4MERpc -Application $doc.Window.Application -Method 'bitmap.present' -Parameters @{
            window_id = $doc.Window.Id; document_id = $doc.Id; bitmap_id = $Bitmap.Id; sequence = $Bitmap.Sequence; slot = $slot })
    } finally { $Bitmap.Busy = $false }
}

function Remove-UI4MELiveBitmap {
    <# .SYNOPSIS Unmaps and releases a live bitmap. Idempotent. -Quiet suppresses host errors. #>
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowNull()]$Bitmap, [switch]$Quiet)
    if ($null -eq $Bitmap -or $Bitmap.Closed) { return }
    $Bitmap.Closed = $true
    if ($null -ne $Bitmap.Mapping) { $Bitmap.Mapping.Dispose() }
    $doc = $Bitmap.Document
    [void]$doc.Bitmaps.Remove($Bitmap)
    $app = $doc.Window.Application
    # Closing a document or window releases its bitmaps in the host already.
    if ($doc.Retired -or $doc.Window.Closed -or $app.Closed -or $app.Disconnected) { return }
    try {
        $null = Invoke-UI4MERpc -Application $app -Method 'bitmap.close' -Parameters @{
            window_id = $doc.Window.Id; document_id = $doc.Id; bitmap_id = $Bitmap.Id }
    } catch {
        $code = $null
        if ($_.Exception -is [UI4ME.RpcException]) { $code = $_.Exception.Code }
        $benign = ($_.Exception -is [UI4ME.DisconnectedException]) -or ($code -in @(-32004, -32008))
        if (-not $Quiet -and -not $benign) { throw }
    }
}

# --------------------------------------------------- host, projects, skills --

function Get-UI4MEHostPath {
    <# .SYNOPSIS Returns the bundled native host for this platform (or UI4ME_HOST_BINARY). #>
    [CmdletBinding()]
    param()
    if ($env:UI4ME_HOST_BINARY) {
        if (-not (Test-Path -LiteralPath $env:UI4ME_HOST_BINARY -PathType Leaf)) { throw "UI4ME_HOST_BINARY does not exist: $env:UI4ME_HOST_BINARY" }
        return [IO.Path]::GetFullPath($env:UI4ME_HOST_BINARY)
    }
    $arch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture
    if ($arch -ne [Runtime.InteropServices.Architecture]::X64) { throw "UI4ME.Core 1.0.0 ships x64 hosts only; this system is $arch" }
    if ($script:UI4MEIsWindows) { $relative = 'runtimes/win-x64/ui4me_core/_bin/ui4me-host.exe' }
    elseif ([Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Linux)) {
        $relative = 'runtimes/linux-x64/ui4me_core/_bin/ui4me-host'
    } else { throw 'UI4ME.Core 1.0.0 supports Linux and Windows only' }
    $found = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot $relative))
    if (-not (Test-Path -LiteralPath $found -PathType Leaf)) {
        throw 'The native UI4ME host is missing. Install UI4ME.Core from the PowerShell Gallery, or set UI4ME_HOST_BINARY.'
    }
    if (-not $script:UI4MEIsWindows) {
        # NuGet package extraction drops Unix execute permissions.
        & /bin/sh -c 'test -x "$1" || chmod u+x -- "$1"' ui4me-host-check $found
        if ($LASTEXITCODE -ne 0) { throw "The native host is not executable and could not be made executable: $found" }
    }
    return $found
}

function Start-UI4MEHost {
    <#
    .SYNOPSIS
    Runs an app manifest (app.toml) through the native host and waits for it to exit.
    .DESCRIPTION
    -NoWait starts the host and returns its process object.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Manifest, [switch]$NoWait)
    $hostBinary = Get-UI4MEHostPath
    $full = Resolve-UI4MEPath $Manifest
    if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { throw "Manifest not found: $full" }
    if ($NoWait) {
        $quoted = '"' + $full.Replace('"', '\"') + '"'
        return (Start-Process -FilePath $hostBinary -ArgumentList @('run', '--app', $quoted) -PassThru)
    }
    & $hostBinary run --app $full
    if ($LASTEXITCODE -ne 0) { throw "UI4ME host exited with code $LASTEXITCODE" }
}

function New-UI4MEProject {
    <#
    .SYNOPSIS
    Generates a runnable HTML + CSS + PowerShell app (app.toml, backend.ps1, index.html, style.css).
    .DESCRIPTION
    The backend runs with pwsh, or with Windows PowerShell 5.1 when -WindowsPowerShell is given.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path, [string]$Name = 'UI4ME PowerShell App',
          [string]$AppId = 'org.example.PowerShellApp', [Alias('WindowsPowerShell51')][switch]$WindowsPowerShell, [switch]$Force)
    if ($AppId -cnotmatch '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$' -or $AppId.Length -gt 255) {
        throw 'AppId must be a reverse-domain ASCII identifier such as org.example.MyApp'
    }
    if ([string]::IsNullOrWhiteSpace($Name) -or $Name.Length -gt 128 -or $Name -match '[\x00-\x1f"\\]') {
        throw 'Name must be 1-128 characters without quotes, backslashes or control characters'
    }
    $folder = Resolve-UI4MEPath $Path
    $files = @('app.toml', 'backend.ps1', 'index.html', 'style.css')
    if (Test-Path -LiteralPath $folder -PathType Leaf) { throw "Path is a file: $folder" }
    if (-not $Force) {
        foreach ($f in $files) { if (Test-Path -LiteralPath (Join-Path $folder $f)) { throw "Refusing to overwrite $f in $folder; use -Force" } }
    }
    [void](New-Item -ItemType Directory -Path $folder -Force)
    $program = if ($WindowsPowerShell) { 'powershell.exe' } else { 'pwsh' }
    $htmlName = [Net.WebUtility]::HtmlEncode($Name)
    $psName = $Name.Replace("'", "''")
    $manifest = @"
[app]
id = "$AppId"
name = "$Name"
version = "1.0.0"

[backend]
program = "$program"
args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "{app_dir}/backend.ps1"]
cwd = "{app_dir}"

[host]
startup_timeout_ms = 20000
shutdown_timeout_ms = 5000
max_windows = 8
instance = "single"
"@

    $backend = @"
# Started by the UI4ME native host (Start-UI4MEHost -Manifest app.toml). Do not run directly.
`$ErrorActionPreference = 'Stop'
Import-Module UI4ME.Core
`$app = Connect-UI4ME
try {
    `$window = New-UI4MEWindow -Application `$app -Title '$psName' -Width 840 -Height 540
    `$doc = Open-UI4MEFile -Window `$window -Path (Join-Path `$PSScriptRoot 'index.html')
    `$script:clicks = 0
    `$null = Register-UI4MEDocumentEvent -Document `$doc -Type click -Element greet -Callback {
        `$script:clicks++
        `$name = (Get-UI4MEValue -Document `$doc -Element name).Trim()
        if (-not `$name) { `$name = 'world' }
        # Text is never parsed as HTML, so user input is safe here.
        Set-UI4MEDocumentText -Document `$doc -Element result -Text "Hello, `$name! (`$(`$script:clicks))"
    }
    Set-UI4MEReady -Application `$app
    Wait-UI4ME -Application `$app -UntilShutdown
} finally {
    Close-UI4ME -Application `$app
}
"@

    $html = @"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>$htmlName</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
  <h1>$htmlName</h1>
  <p>Native HTML and CSS with PowerShell logic. No browser or JavaScript.</p>
  <label for="name">Your name</label>
  <div class="row"><input id="name" type="text" autocomplete="off"><button id="greet">Greet</button></div>
  <p id="result" aria-live="polite">Ready.</p>
</main>
</body>
</html>
"@

    $css = @'
:root { color-scheme: light dark; --bg: #f4f6fb; --card: #ffffff; --text: #17233a; --accent: #3268d8; }
@media (prefers-color-scheme: dark) { :root { --bg: #151a24; --card: #1f2633; --text: #e6ebf5; --accent: #6f9bff; } }
body { margin: 0; padding: 32px; font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); }
main { max-width: 640px; margin: 0 auto; padding: 28px 32px; background: var(--card); border-radius: 14px; }
.row { display: flex; gap: 10px; margin-top: 6px; }
input { flex: 1; padding: 9px 11px; font-size: 15px; border-radius: 8px; border: 1px solid #8894a8; }
button { padding: 9px 18px; font-size: 15px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; }
button:focus-visible, input:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
'@

    $utf8 = New-Object Text.UTF8Encoding($false)
    [IO.File]::WriteAllText((Join-Path $folder 'app.toml'), $manifest.Replace("`r`n", "`n"), $utf8)
    # Windows PowerShell 5.1 reads BOM-less scripts as the ANSI code page.
    [IO.File]::WriteAllText((Join-Path $folder 'backend.ps1'), $backend, (New-Object Text.UTF8Encoding($true)))
    [IO.File]::WriteAllText((Join-Path $folder 'index.html'), $html.Replace("`r`n", "`n"), $utf8)
    [IO.File]::WriteAllText((Join-Path $folder 'style.css'), $css.Replace("`r`n", "`n"), $utf8)
    return (Get-Item -LiteralPath $folder)
}

function Get-UI4MESkillHashes {
    param([string]$Path)
    $hashes = @{}
    $root = [IO.Path]::GetFullPath($Path).TrimEnd([char]'\', [char]'/')
    foreach ($entry in @(Get-ChildItem -LiteralPath $root -Recurse -Force)) {
        if (([int]$entry.Attributes -band [int][IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Links are not supported in skill folders: $($entry.FullName)" }
        if ($entry.PSIsContainer -or $entry.Name -eq '.ui4me-managed.json') { continue }
        $relative = $entry.FullName.Substring($root.Length).TrimStart([char]'\', [char]'/').Replace('\', '/')
        $hashes[$relative] = (Get-FileHash -LiteralPath $entry.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
    }
    if (-not $hashes.ContainsKey('SKILL.md')) { throw 'A skill must contain SKILL.md' }
    return $hashes
}
function Get-UI4MESkillState {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) { return 'missing' }
    $item = Get-Item -LiteralPath $Path -Force
    if (-not $item.PSIsContainer -or (([int]$item.Attributes -band [int][IO.FileAttributes]::ReparsePoint) -ne 0)) { return 'unmanaged' }
    $marker = Join-Path $Path '.ui4me-managed.json'
    if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { return 'unmanaged' }
    try {
        $record = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($marker, [Text.Encoding]::UTF8))
        if ($record.name -ne 'ui4me-powershell') { return 'unmanaged' }
        $actual = Get-UI4MESkillHashes $Path
        $known = @($record.files.PSObject.Properties)
        if ($actual.Count -ne $known.Count) { return 'modified' }
        foreach ($e in $known) { if (-not $actual.ContainsKey($e.Name) -or $actual[$e.Name] -ne $e.Value) { return 'modified' } }
        return 'installed'
    } catch { return 'modified' }
}
function Get-UI4MESkillSource {
    # A Gallery install bundles skills/ in the module; a source checkout keeps it at the repo root.
    foreach ($candidate in @((Join-Path $PSScriptRoot 'skills/ui4me-powershell'), (Join-Path $PSScriptRoot '../../skills/ui4me-powershell'))) {
        $full = [IO.Path]::GetFullPath($candidate)
        if (Test-Path -LiteralPath (Join-Path $full 'SKILL.md') -PathType Leaf) { return $full }
    }
    return $null
}

function Install-UI4MESkill {
    <#
    .SYNOPSIS
    Installs, inspects or removes the ui4me-powershell coding-agent skill for Claude Code, Codex,
    OpenCode, GitHub Copilot, Cursor and Gemini CLI.
    .DESCRIPTION
    User scope writes under your home directory; project scope under -ProjectPath. Folders that were
    not installed by this command, or were edited since, are never replaced or removed without -Force.
    .EXAMPLE
    Install-UI4MESkill -Agents all -Scope user
    .EXAMPLE
    Install-UI4MESkill -Action status -Agents claude,codex -Scope project -ProjectPath .
    #>

    [CmdletBinding()]
    param([ValidateSet('install', 'status', 'uninstall')][string]$Action = 'install',
      [string[]]$Agents = @('all'), [ValidateSet('user', 'project')][string]$Scope = 'user',
      [string]$ProjectPath, [switch]$Force, [switch]$DryRun)
    if ($Scope -eq 'project' -and -not $ProjectPath) { throw '-ProjectPath is required with -Scope project' }
    if ($Scope -eq 'user' -and $ProjectPath) { throw '-ProjectPath applies only to -Scope project' }
    $source = Get-UI4MESkillSource
    if ($Action -eq 'install' -and $null -eq $source) {
        throw 'The ui4me-powershell skill is missing from this module. Reinstall UI4ME.Core from the PowerShell Gallery.'
    }
    $supported = @('claude', 'codex', 'opencode', 'copilot', 'cursor', 'gemini')
    $tokens = @($Agents | ForEach-Object { $_.Split(',') } | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique)
    $targets = if ($tokens -contains 'all') { $supported } else { $tokens }
    foreach ($n in $targets) { if ($n -notin $supported) { throw "Unsupported agent '$n'. Use: $($supported -join ', '), all" } }
    $map = @{ claude = '.claude/skills'; codex = '.agents/skills'; opencode = '.config/opencode/skills'
        copilot = '.copilot/skills'; cursor = '.cursor/skills'; gemini = '.gemini/skills' }
    if ($Scope -eq 'project') {
        $base = Resolve-UI4MEPath $ProjectPath
        if (-not (Test-Path -LiteralPath $base -PathType Container)) { throw "-ProjectPath must be an existing folder: $base" }
        $map.opencode = '.opencode/skills'; $map.copilot = '.github/skills'
    } else {
        $base = [Environment]::GetFolderPath('UserProfile')
        if (-not $base) { $base = $HOME }
    }
    foreach ($agent in $targets) {
        $dest = [IO.Path]::GetFullPath((Join-Path (Join-Path $base $map[$agent]) 'ui4me-powershell'))
        # Never write through a link/junction in the destination path.
        $ancestor = $dest
        while ($ancestor) {
            if (Test-Path -LiteralPath $ancestor) {
                $f = Get-Item -LiteralPath $ancestor -Force
                if (([int]$f.Attributes -band [int][IO.FileAttributes]::ReparsePoint) -ne 0 -and $ancestor.Length -gt $base.TrimEnd([char]'\', [char]'/').Length) {
                    throw "Refusing to use a linked folder: $ancestor"
                }
            }
            $parent = Split-Path -Parent $ancestor
            if (-not $parent -or $parent -eq $ancestor) { break }
            $ancestor = $parent
        }
        $state = Get-UI4MESkillState $dest
        if ($Action -eq 'status' -or $DryRun) {
            [pscustomobject]@{ Agent = $agent; Scope = $Scope; State = $state; Path = $dest
                Action = $(if ($DryRun) { "would-$Action" } else { 'status' }) }
            continue
        }
        if ($Action -eq 'uninstall') {
            if ($state -eq 'missing') { [pscustomobject]@{ Agent = $agent; Scope = $Scope; State = 'missing'; Path = $dest; Action = 'none' }; continue }
            if ($state -ne 'installed' -and -not $Force) { throw "Refusing to remove $state skill folder $dest (use -Force)" }
            Remove-Item -LiteralPath $dest -Recurse -Force
            [pscustomobject]@{ Agent = $agent; Scope = $Scope; State = 'missing'; Path = $dest; Action = 'removed' }
            continue
        }
        if ($state -in @('modified', 'unmanaged') -and -not $Force) {
            throw "Refusing to overwrite $state skill folder $dest; inspect it or use -Force"
        }
        $parent = Split-Path -Parent $dest
        [void](New-Item -ItemType Directory -Path $parent -Force)
        $temp = Join-Path $parent ('.ui4me-skill-new-' + [guid]::NewGuid().ToString('N'))
        $backup = Join-Path $parent ('.ui4me-skill-old-' + [guid]::NewGuid().ToString('N'))
        try {
            Copy-Item -LiteralPath $source -Destination $temp -Recurse
            $files = Get-UI4MESkillHashes $temp
            $record = @{ name = 'ui4me-powershell'; version = $script:UI4MEVersion; files = $files } | ConvertTo-Json -Depth 5
            [IO.File]::WriteAllText((Join-Path $temp '.ui4me-managed.json'), $record, (New-Object Text.UTF8Encoding($false)))
            if (Test-Path -LiteralPath $dest) { Move-Item -LiteralPath $dest -Destination $backup }
            try { Move-Item -LiteralPath $temp -Destination $dest }
            catch { if (Test-Path -LiteralPath $backup) { Move-Item -LiteralPath $backup -Destination $dest }; throw }
            if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Recurse -Force }
            [pscustomobject]@{ Agent = $agent; Scope = $Scope; State = 'installed'; Path = $dest; Action = 'installed' }
        } finally {
            if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Recurse -Force }
        }
    }
}

Export-ModuleMember -Function @(
    'Connect-UI4ME', 'Close-UI4ME', 'Wait-UI4ME', 'Invoke-UI4MERpc', 'Send-UI4MENotification',
    'Start-UI4MERpc', 'Wait-UI4MERpc', 'Stop-UI4MERpc',
    'New-UI4MEWindow', 'Set-UI4MEWindow', 'Get-UI4MEWindow', 'Get-UI4MEWindowList', 'Close-UI4MEWindow',
    'Request-UI4MEAttention', 'Set-UI4MEBeforeClose', 'Set-UI4MEBeforeQuit', 'Set-UI4MEReady', 'Request-UI4MEQuit',
    'Register-UI4MENotification',
    'Open-UI4MEHtml', 'Open-UI4MEFile', 'Remove-UI4MEDocument',
    'Register-UI4MEDocumentEvent', 'Unregister-UI4MEDocumentEvent', 'Invoke-UI4MEDocumentCommand', 'Invoke-UI4MEBatch',
    'Get-UI4MEDocumentText', 'Set-UI4MEDocumentText', 'Get-UI4MEValue', 'Set-UI4MEDocumentValue',
    'Get-UI4MEChecked', 'Set-UI4MEChecked', 'Set-UI4MEDocumentAttribute', 'Set-UI4MEClass', 'Set-UI4MEDocumentHtml',
    'Remove-UI4MEElement', 'Find-UI4MEElement',
    'Add-UI4MEStylesheet', 'Get-UI4MEStylesheets', 'Set-UI4MEStylesheet', 'Get-UI4MEComputedStyle',
    'Get-UI4MEEnvironment', 'Set-UI4MEEnvironment', 'Get-UI4MEScroll', 'Set-UI4MEScroll',
    'Get-UI4MEFocusedElement', 'Set-UI4MEFocus', 'Get-UI4MESelection', 'Set-UI4MESelection', 'Invoke-UI4MEEditCommand',
    'Get-UI4MEFormData', 'Submit-UI4MEForm', 'Reset-UI4MEForm', 'Get-UI4MESelectOptions', 'Set-UI4MESelectedValues',
    'Show-UI4MEPopover', 'Show-UI4MEHtmlDialog', 'Set-UI4MEPreventDefault',
    'Get-UI4MEDiagnostics', 'Get-UI4MEResourceStats', 'Clear-UI4MEUnusedResources',
    'Register-UI4MEResources', 'Unregister-UI4MEResources',
    'Get-UI4MENode', 'Get-UI4MENodes', 'Get-UI4MENodeInfo', 'Get-UI4MENodeChildren', 'Invoke-UI4MENodeBatch', 'Invoke-UI4MENodeOperation',
    'Get-UI4MECapabilities', 'Get-UI4MEPreferences', 'Get-UI4MEClipboard', 'Set-UI4MEClipboard',
    'Open-UI4MEExternal', 'Show-UI4MEFileDialog',
    'New-UI4MELiveBitmap', 'Send-UI4MELiveBitmap', 'Remove-UI4MELiveBitmap',
    'Get-UI4MEHostPath', 'Start-UI4MEHost', 'New-UI4MEProject', 'Install-UI4MESkill'
)