PoshWallpaper.psm1

# PoshWallpaper - Multi-monitor animated wallpaper manager
# Part of the PoshDE ecosystem

$script:ModuleRoot        = $PSScriptRoot
$script:Version           = '1.0.0'
$script:ActivePort        = $null
$script:WindowProcess     = $null
$script:ServerRunspace    = $null
$script:ServerPowerShell  = $null
$script:ServerAsyncResult = $null
$script:SharedState       = $null
$script:ConfigDir         = Join-Path $env:APPDATA "PoshDE\PoshWallpaper"
$script:ConfigFile        = Join-Path $script:ConfigDir "config.json"
$script:UserWallpapersDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) "PoshWallpaper\Wallpapers"

if (-not (Get-Module -ListAvailable -Name PoshDE)) {
    Write-Error "PoshWallpaper requires PoshDE. Install it first."
    return
}
Import-Module PoshDE -ErrorAction Stop

# ============================================================================
# PS5.1 launcher — written to disk per-monitor, runs WebView2 fullscreen
# ============================================================================
$script:LauncherScript = @'
param($HtmlFile, $X, $Y, $Width, $Height, $DllPath, $MonitorIndex)

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

[System.Reflection.Assembly]::LoadFrom("$DllPath\Microsoft.Web.WebView2.Core.dll") | Out-Null
[System.Reflection.Assembly]::LoadFrom("$DllPath\Microsoft.Web.WebView2.WinForms.dll") | Out-Null

$userDataFolder = "$env:LOCALAPPDATA\PoshWallpaper\WebView2_Monitor$MonitorIndex"
New-Item -Path $userDataFolder -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null

Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WallpaperApi {
    [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hwnd, int index);
    [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hwnd, int index, int value);
    [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
    public static readonly IntPtr HWND_BOTTOM = new IntPtr(1);
    public const int GWL_EXSTYLE = -20;
    public const int WS_EX_TOOLWINDOW = 0x80;
    public const int WS_EX_NOACTIVATE = 0x08000000;
    public const uint SWP_NOMOVE = 0x0002;
    public const uint SWP_NOSIZE = 0x0001;
    public const uint SWP_NOACTIVATE = 0x0010;
}
"@

$form = New-Object System.Windows.Forms.Form
$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
$form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
$form.Location = New-Object System.Drawing.Point($X, $Y)
$form.Size = New-Object System.Drawing.Size($Width, $Height)
$form.BackColor = [System.Drawing.Color]::Black
$form.ShowInTaskbar = $false
$form.TopMost = $false

$webView = New-Object Microsoft.Web.WebView2.WinForms.WebView2
$webView.Dock = [System.Windows.Forms.DockStyle]::Fill
$webView.DefaultBackgroundColor = [System.Drawing.Color]::Black
$webView.CreationProperties = New-Object Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties
$webView.CreationProperties.UserDataFolder = $userDataFolder

$form.Controls.Add($webView)

$form.Add_Load({
    $hwnd = $form.Handle
    $exStyle = [WallpaperApi]::GetWindowLong($hwnd, [WallpaperApi]::GWL_EXSTYLE)
    [WallpaperApi]::SetWindowLong($hwnd, [WallpaperApi]::GWL_EXSTYLE,
        $exStyle -bor [WallpaperApi]::WS_EX_TOOLWINDOW -bor [WallpaperApi]::WS_EX_NOACTIVATE)
    [WallpaperApi]::SetWindowPos($hwnd, [WallpaperApi]::HWND_BOTTOM, 0, 0, 0, 0,
        ([WallpaperApi]::SWP_NOMOVE -bor [WallpaperApi]::SWP_NOSIZE -bor [WallpaperApi]::SWP_NOACTIVATE))
})

$webView.Add_CoreWebView2InitializationCompleted({
    param($sender, $e)
    if ($e.IsSuccess) {
        $webView.CoreWebView2.Settings.IsStatusBarEnabled = $false
        $webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = $false
        $webView.CoreWebView2.Settings.AreDevToolsEnabled = $false
        $fileUrl = "file:///" + $HtmlFile.Replace('\', '/')
        $webView.CoreWebView2.Navigate($fileUrl)
    }
})

$form.Add_Shown({ $webView.EnsureCoreWebView2Async($null) })
[System.Windows.Forms.Application]::Run($form)
'@


# ============================================================================
# Config helpers
# ============================================================================
function Initialize-PoshWallpaperConfig {
    if (-not (Test-Path $script:ConfigDir)) {
        New-Item -Path $script:ConfigDir -ItemType Directory -Force | Out-Null
    }
    if (-not (Test-Path $script:UserWallpapersDir)) {
        New-Item -Path $script:UserWallpapersDir -ItemType Directory -Force | Out-Null
    }
}

function Import-PoshWallpaperConfig {
    Initialize-PoshWallpaperConfig
    $cfg = @{ Assignments = @{}; Slideshow = @{} }
    if (Test-Path $script:ConfigFile) {
        try {
            $json = Get-Content $script:ConfigFile -Raw | ConvertFrom-Json
            if ($json.Assignments) {
                $json.Assignments.PSObject.Properties | ForEach-Object { $cfg.Assignments[$_.Name] = $_.Value }
            }
        } catch { }
    }
    return $cfg
}

# ============================================================================
# Public: Get-PoshMonitors
# ============================================================================
function Get-PoshMonitors {
    <#
    .SYNOPSIS List all connected monitors with size, position, and wallpaper status.
    .EXAMPLE Get-PoshMonitors
    #>

    [CmdletBinding()]
    param()
    Add-Type -AssemblyName System.Windows.Forms
    $i = 0
    foreach ($screen in [System.Windows.Forms.Screen]::AllScreens) {
        $key     = "$i"
        $running = $false
        $current = $null
        if ($script:SharedState) {
            if ($script:SharedState.WallpaperProcesses.ContainsKey($key)) {
                $p = $script:SharedState.WallpaperProcesses[$key]
                $running = ($p -and -not $p.HasExited)
            }
            $current = $script:SharedState.Config.Assignments[$key]
        }
        [PSCustomObject]@{
            Index            = $i
            DeviceName       = $screen.DeviceName
            X                = $screen.Bounds.X
            Y                = $screen.Bounds.Y
            Width            = $screen.Bounds.Width
            Height           = $screen.Bounds.Height
            Primary          = $screen.Primary
            Running          = $running
            CurrentWallpaper = $current
        }
        $i++
    }
}

# ============================================================================
# Public: Get-PoshWallpapers
# ============================================================================
function Get-PoshWallpapers {
    <#
    .SYNOPSIS List all available wallpapers (built-in + user-added).
    .DESCRIPTION
        Built-in wallpapers live in the module's Wallpapers\ folder.
        Each wallpaper is a subfolder containing an HTML file with the same name,
        plus any assets it needs (images, JSON, etc.).
        Example: Wallpapers\MatrixRain\MatrixRain.html

        User wallpapers live in:
            $env:APPDATA\PoshDE\PoshWallpaper\Wallpapers\
        Drop a folder there with a matching HTML file and it appears automatically.
        This folder survives module updates.
    .EXAMPLE Get-PoshWallpapers
    #>

    [CmdletBinding()]
    param()
    Initialize-PoshWallpaperConfig
    $seen = @{}
    $builtinDir = Join-Path $script:ModuleRoot "Wallpapers"
    foreach ($dir in @($builtinDir, $script:UserWallpapersDir)) {
        if (-not (Test-Path $dir)) { continue }
        foreach ($folder in (Get-ChildItem $dir -Directory -ErrorAction SilentlyContinue)) {
            if ($seen.ContainsKey($folder.Name)) { continue }
            $htmlFile = Join-Path $folder.FullName "$($folder.Name).html"
            if (-not (Test-Path $htmlFile)) { continue }
            $seen[$folder.Name] = $true
            $content     = Get-Content $htmlFile -Raw -ErrorAction SilentlyContinue
            $description = if ($content -match '<!--\s*Description:\s*(.+?)\s*-->') { $Matches[1] } else { "Animated wallpaper" }
            $tags        = if ($content -match '<!--\s*Tags:\s*(.+?)\s*-->') { $Matches[1] -split '\s*,\s*' } else { @() }
            $source      = if ($dir -eq $builtinDir) { 'builtin' } else { 'user' }
            [PSCustomObject]@{
                Name        = $folder.Name
                Description = $description
                Tags        = $tags
                Path        = $htmlFile
                Source      = $source
            }
        }
    }
}

# ============================================================================
# Public: Start-PoshWallpaper (CLI)
# ============================================================================
function Start-PoshWallpaper {
    <#
    .SYNOPSIS Start a wallpaper on one or all monitors from the CLI.
    .PARAMETER Name Wallpaper name.
    .PARAMETER Monitor Monitor index (default 0).
    .PARAMETER AllMonitors Apply to every monitor.
    .EXAMPLE Start-PoshWallpaper -Name "MatrixRain"
    .EXAMPLE Start-PoshWallpaper -Name "SynthwaveGrid" -AllMonitors
    #>

    [CmdletBinding(DefaultParameterSetName='Single')]
    param(
        [Parameter(Mandatory)]
        [string]$Name,
        [Parameter(ParameterSetName='Single')]
        [int]$Monitor = 0,
        [Parameter(ParameterSetName='All')]
        [switch]$AllMonitors
    )

    $dllPath = Get-WebView2DllPath
    if (-not $dllPath) { Write-Error "WebView2 not ready."; return }

    if (-not $script:SharedState) {
        $script:SharedState = [hashtable]::Synchronized(@{
            Running            = $false
            WallpaperProcesses = [hashtable]::Synchronized(@{})
            Config             = Import-PoshWallpaperConfig
            DllPath            = $dllPath
            ModuleRoot         = $script:ModuleRoot
            UserWallpapersDir  = $script:UserWallpapersDir
            LauncherScript     = $script:LauncherScript
        })
    }

    Add-Type -AssemblyName System.Windows.Forms
    $targets = if ($AllMonitors) { 0..([System.Windows.Forms.Screen]::AllScreens.Count - 1) } else { @($Monitor) }

    foreach ($idx in $targets) {
        $key = "$idx"

        # Find wallpaper — look for Name\Name.html in built-in then user dir
        $htmlPath = $null
        $builtinDir = Join-Path $script:ModuleRoot "Wallpapers"
        foreach ($dir in @($builtinDir, $script:UserWallpapersDir)) {
            $c = Join-Path $dir "$Name\$Name.html"
            if (Test-Path $c) { $htmlPath = $c; break }
        }
        if (-not $htmlPath) { Write-Warning "Wallpaper not found: $Name"; continue }

        # Kill existing
        if ($script:SharedState.WallpaperProcesses.ContainsKey($key)) {
            $old = $script:SharedState.WallpaperProcesses[$key]
            if ($old -and -not $old.HasExited) { try { $old.Kill(); $old.WaitForExit(3000) } catch { } }
            $script:SharedState.WallpaperProcesses.Remove($key)
        }

        $b = [System.Windows.Forms.Screen]::AllScreens[$idx].Bounds

        $launcherPath = Join-Path $env:TEMP "PoshWallpaper_M$idx.ps1"
        $script:LauncherScript | Set-Content $launcherPath -Encoding UTF8 -Force

        $argStr = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden" +
                  " -File `"$launcherPath`"" +
                  " -HtmlFile `"$htmlPath`"" +
                  " -X $($b.X) -Y $($b.Y) -Width $($b.Width) -Height $($b.Height)" +
                  " -DllPath `"$dllPath`"" +
                  " -MonitorIndex $idx"

        $proc = Start-Process -FilePath "powershell.exe" -ArgumentList $argStr -PassThru -WindowStyle Hidden
        if ($proc) {
            $script:SharedState.WallpaperProcesses[$key] = $proc
            $script:SharedState.Config.Assignments[$key] = $Name
            # Persist
            $toSave = @{ Assignments = $script:SharedState.Config.Assignments; Slideshow = @{} }
            try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $script:ConfigFile -Encoding UTF8 } catch { }
            Write-Host " [+] Monitor $idx $Name (PID $($proc.Id))" -ForegroundColor Green
        } else {
            Write-Warning "Failed to launch wallpaper on monitor $idx"
        }
    }
}

# ============================================================================
# Public: Stop-PoshWallpaper (CLI)
# ============================================================================
function Stop-PoshWallpaper {
    <#
    .SYNOPSIS Stop wallpaper on one or all monitors.
    .PARAMETER Monitor Monitor index. Omit to stop all.
    .EXAMPLE Stop-PoshWallpaper
    .EXAMPLE Stop-PoshWallpaper -Monitor 1
    #>

    [CmdletBinding()]
    param([int]$Monitor = -1)
    if (-not $script:SharedState) { Write-Host "No wallpapers running." -ForegroundColor DarkGray; return }
    $keys = if ($Monitor -ge 0) { @("$Monitor") } else { @($script:SharedState.WallpaperProcesses.Keys) }
    foreach ($key in $keys) {
        if ($script:SharedState.WallpaperProcesses.ContainsKey($key)) {
            $p = $script:SharedState.WallpaperProcesses[$key]
            if ($p -and -not $p.HasExited) { try { $p.Kill(); $p.WaitForExit(3000) } catch { } }
            $script:SharedState.WallpaperProcesses.Remove($key)
            $script:SharedState.Config.Assignments.Remove($key)
            Write-Host " [-] Monitor $key stopped" -ForegroundColor Yellow
        }
    }
    $toSave = @{ Assignments = $script:SharedState.Config.Assignments; Slideshow = @{} }
    try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $script:ConfigFile -Encoding UTF8 } catch { }
}

# ============================================================================
# Public: Restore-PoshWallpaperState
# ============================================================================
function Restore-PoshWallpaperState {
    <#
    .SYNOPSIS Reapply all saved wallpaper assignments.
    .EXAMPLE Restore-PoshWallpaperState
    #>

    [CmdletBinding()]
    param()
    $cfg = Import-PoshWallpaperConfig
    if ($cfg.Assignments.Count -eq 0) { Write-Host " Nothing to restore." -ForegroundColor DarkGray; return }
    foreach ($key in $cfg.Assignments.Keys) {
        Start-PoshWallpaper -Name $cfg.Assignments[$key] -Monitor ([int]$key)
    }
}

# ============================================================================
# HTTP Server — background runspace, handles all API calls
# Start-Process works fine in a runspace — no queue/watcher needed
# ============================================================================
$script:ServerScript = {
    param(
        [int]       $Port,
        [string]    $PublicPath,
        [string]    $PoshDEPath,
        [hashtable] $SharedState
    )

    Import-Module $PoshDEPath -ErrorAction Stop

    function Send-Json {
        param($Ctx, $Data, [int]$Code = 200)
        $json = $Data | ConvertTo-Json -Depth 10 -Compress
        $buf  = [System.Text.Encoding]::UTF8.GetBytes($json)
        $Ctx.Response.StatusCode      = $Code
        $Ctx.Response.ContentType     = 'application/json; charset=utf-8'
        $Ctx.Response.ContentLength64 = $buf.Length
        $Ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
        $Ctx.Response.Close()
    }

    function Send-Text {
        param($Ctx, [string]$Text, [string]$ContentType = 'text/plain; charset=utf-8', [int]$Code = 200)
        $buf = [System.Text.Encoding]::UTF8.GetBytes($Text)
        $Ctx.Response.StatusCode      = $Code
        $Ctx.Response.ContentType     = $ContentType
        $Ctx.Response.ContentLength64 = $buf.Length
        $Ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
        $Ctx.Response.Close()
    }

    function Send-File {
        param($Ctx, [string]$Path, [string]$ContentType)
        if (Test-Path $Path) {
            $bytes = [System.IO.File]::ReadAllBytes($Path)
            $Ctx.Response.StatusCode      = 200
            $Ctx.Response.ContentType     = $ContentType
            $Ctx.Response.ContentLength64 = $bytes.Length
            $Ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
        } else {
            $buf = [System.Text.Encoding]::UTF8.GetBytes("Not Found")
            $Ctx.Response.StatusCode      = 404
            $Ctx.Response.ContentLength64 = $buf.Length
            $Ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
        }
        $Ctx.Response.Close()
    }

    function Read-JsonBody {
        param($Req)
        $reader = New-Object System.IO.StreamReader($Req.InputStream)
        $body   = $reader.ReadToEnd(); $reader.Close()
        return $body | ConvertFrom-Json
    }

    function Get-WallpaperList {
        $seen = @{}
        $list = [System.Collections.Generic.List[object]]::new()
        $builtinDir = Join-Path $SharedState.ModuleRoot "Wallpapers"
        foreach ($dir in @($builtinDir, $SharedState.UserWallpapersDir)) {
            if (-not (Test-Path $dir)) { continue }
            foreach ($folder in (Get-ChildItem $dir -Directory -ErrorAction SilentlyContinue)) {
                if ($seen.ContainsKey($folder.Name)) { continue }
                $htmlFile = Join-Path $folder.FullName "$($folder.Name).html"
                if (-not (Test-Path $htmlFile)) { continue }
                $seen[$folder.Name] = $true
                $content     = Get-Content $htmlFile -Raw -ErrorAction SilentlyContinue
                $description = if ($content -match '<!--\s*Description:\s*(.+?)\s*-->') { $Matches[1] } else { "Animated wallpaper" }
                $tags        = if ($content -match '<!--\s*Tags:\s*(.+?)\s*-->') { @($Matches[1] -split '\s*,\s*') } else { @() }
                $source      = if ($dir -eq $builtinDir) { 'builtin' } else { 'user' }
                $list.Add(@{ name=$folder.Name; description=$description; tags=$tags; path=$htmlFile; source=$source })
            }
        }
        return @($list)
    }

    function Resolve-WallpaperPath {
        param([string]$Name)
        $builtinDir = Join-Path $SharedState.ModuleRoot "Wallpapers"
        foreach ($dir in @($builtinDir, $SharedState.UserWallpapersDir)) {
            $c = Join-Path $dir "$Name\$Name.html"
            if (Test-Path $c) { return $c }
        }
        return $null
    }

    function Get-MonitorList {
        Add-Type -AssemblyName System.Windows.Forms
        $list = [System.Collections.Generic.List[object]]::new()
        $i = 0
        foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
            $key     = "$i"
            $running = $false
            $current = $null
            if ($SharedState.WallpaperProcesses.ContainsKey($key)) {
                $p = $SharedState.WallpaperProcesses[$key]
                if ($p -and -not $p.HasExited) { $running = $true }
            }
            if ($SharedState.Config.Assignments.ContainsKey($key)) {
                $current = $SharedState.Config.Assignments[$key]
            }
            $sv = $null
            if ($SharedState.Config.Slideshow.ContainsKey($key)) {
                $v  = $SharedState.Config.Slideshow[$key]
                $sv = @{ enabled=$v.Enabled; intervalMinutes=$v.IntervalMinutes; shuffle=$v.Shuffle }
            }
            $list.Add(@{
                index=$i; deviceName=$s.DeviceName
                x=$s.Bounds.X; y=$s.Bounds.Y; width=$s.Bounds.Width; height=$s.Bounds.Height
                primary=$s.Primary; running=$running; currentWallpaper=$current; slideshow=$sv
            })
            $i++
        }
        return @($list)
    }

    # Apply a wallpaper directly — Start-Process works fine in a runspace
    function Invoke-Apply {
        param([int]$MonitorIndex, [string]$WallpaperName)

        $key      = "$MonitorIndex"
        $htmlPath = Resolve-WallpaperPath -Name $WallpaperName
        if (-not $htmlPath) { return @{ success=$false; error="Wallpaper not found: $WallpaperName" } }

        # Kill existing on this monitor
        if ($SharedState.WallpaperProcesses.ContainsKey($key)) {
            $old = $SharedState.WallpaperProcesses[$key]
            if ($old -and -not $old.HasExited) {
                try { $old.Kill(); $old.WaitForExit(3000) } catch { }
            }
            $SharedState.WallpaperProcesses.Remove($key)
        }

        Add-Type -AssemblyName System.Windows.Forms
        $screens = [System.Windows.Forms.Screen]::AllScreens
        if ($MonitorIndex -ge $screens.Count) { return @{ success=$false; error="Monitor $MonitorIndex not found" } }
        $b = $screens[$MonitorIndex].Bounds

        $launcherPath = Join-Path $env:TEMP "PoshWallpaper_M$MonitorIndex.ps1"
        $SharedState.LauncherScript | Set-Content $launcherPath -Encoding UTF8 -Force

        # MUST be a single string — array breaks on negative X (e.g. -1920 parsed as a flag)
        $argStr = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden" +
                  " -File `"$launcherPath`"" +
                  " -HtmlFile `"$htmlPath`"" +
                  " -X $($b.X) -Y $($b.Y) -Width $($b.Width) -Height $($b.Height)" +
                  " -DllPath `"$($SharedState.DllPath)`"" +
                  " -MonitorIndex $MonitorIndex"

        $proc = Start-Process -FilePath "powershell.exe" -ArgumentList $argStr -PassThru -WindowStyle Hidden
        if (-not $proc) { return @{ success=$false; error="Start-Process returned null" } }

        $SharedState.WallpaperProcesses[$key] = $proc
        $SharedState.Config.Assignments[$key] = $WallpaperName

        $toSave = @{ Assignments=$SharedState.Config.Assignments; Slideshow=@{} }
        foreach ($sk in $SharedState.Config.Slideshow.Keys) {
            $v = $SharedState.Config.Slideshow[$sk]
            $toSave.Slideshow[$sk] = @{ Enabled=$v.Enabled; IntervalMinutes=$v.IntervalMinutes; Shuffle=$v.Shuffle }
        }
        $cfgFile = Join-Path $env:APPDATA "PoshDE\PoshWallpaper\config.json"
        try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $cfgFile -Encoding UTF8 } catch { }

        return @{ success=$true; pid=$proc.Id }
    }

    function Invoke-Stop {
        param([int]$MonitorIndex)
        $key = "$MonitorIndex"
        if ($SharedState.WallpaperProcesses.ContainsKey($key)) {
            $p = $SharedState.WallpaperProcesses[$key]
            if ($p -and -not $p.HasExited) { try { $p.Kill(); $p.WaitForExit(3000) } catch { } }
            $SharedState.WallpaperProcesses.Remove($key)
        }
        $SharedState.Config.Assignments.Remove($key)
        $toSave = @{ Assignments=$SharedState.Config.Assignments; Slideshow=@{} }
        foreach ($sk in $SharedState.Config.Slideshow.Keys) {
            $v = $SharedState.Config.Slideshow[$sk]
            $toSave.Slideshow[$sk] = @{ Enabled=$v.Enabled; IntervalMinutes=$v.IntervalMinutes; Shuffle=$v.Shuffle }
        }
        $cfgFile = Join-Path $env:APPDATA "PoshDE\PoshWallpaper\config.json"
        try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $cfgFile -Encoding UTF8 } catch { }
    }

    function Invoke-SlideshowTick {
        $now = Get-Date
        foreach ($key in @($SharedState.Config.Slideshow.Keys)) {
            $sv = $SharedState.Config.Slideshow[$key]
            if (-not $sv.Enabled) { continue }
            if (($now - $sv.LastChanged).TotalMinutes -lt $sv.IntervalMinutes) { continue }
            $all = @(Get-WallpaperList)
            if ($all.Count -eq 0) { continue }
            $next = if ($sv.Shuffle) {
                ($all | Get-Random).name
            } else {
                $names = @($all | ForEach-Object { $_.name })
                $idx   = [array]::IndexOf($names, $SharedState.Config.Assignments[$key])
                $names[($idx + 1) % $names.Count]
            }
            Invoke-Apply -MonitorIndex ([int]$key) -WallpaperName $next | Out-Null
            $sv.LastChanged = $now
        }
    }

    # ── HTTP Listener ────────────────────────────────────────────────────────
    $listener = New-Object System.Net.HttpListener
    $listener.Prefixes.Add("http://localhost:$Port/")
    $listener.Prefixes.Add("http://127.0.0.1:$Port/")
    $listener.Start()
    $SharedState.Ready = $true

    try {
        while ($SharedState.Running) {
            $task = $listener.GetContextAsync()
            while (-not $task.Wait(500)) { if (-not $SharedState.Running) { break } }
            Invoke-SlideshowTick
            if (-not $SharedState.Running) { break }
            if (-not $task.IsCompleted -or $task.IsFaulted -or $task.IsCanceled) { continue }

            $ctx     = $task.Result
            $req     = $ctx.Request
            $urlPath = $req.Url.LocalPath

            $ctx.Response.Headers.Add('Access-Control-Allow-Origin',  '*')
            $ctx.Response.Headers.Add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
            $ctx.Response.Headers.Add('Access-Control-Allow-Headers', 'Content-Type')

            try {
                if ($req.HttpMethod -eq 'OPTIONS') {
                    $ctx.Response.StatusCode = 200; $ctx.Response.Close(); continue
                }

                if ($urlPath -eq '/' -or $urlPath -eq '/index.html') {
                    Send-File $ctx (Join-Path $SharedState.ModuleRoot 'index.html') 'text/html; charset=utf-8'
                }
                elseif ($urlPath -eq '/api/theme') {
                    $t = Get-PoshTheme
                    Send-Json $ctx @{ name=$t.Name; css=(Export-PoshThemeCSS); colors=$t.Colors }
                }
                elseif ($urlPath -eq '/api/monitors') {
                    Send-Json $ctx @{ monitors = @(Get-MonitorList) }
                }
                elseif ($urlPath -eq '/api/wallpapers') {
                    Send-Json $ctx @{ wallpapers = @(Get-WallpaperList) }
                }
                elseif ($urlPath -match '^/api/wallpaper/(.+)$') {
                    $name = [Uri]::UnescapeDataString($Matches[1])
                    $path = Resolve-WallpaperPath -Name $name
                    if ($path) {
                        Send-Text $ctx (Get-Content $path -Raw -Encoding UTF8) 'text/html; charset=utf-8'
                    } else {
                        Send-Json $ctx @{ error="Not found: $name" } -Code 404
                    }
                }
                elseif ($urlPath -eq '/api/apply' -and $req.HttpMethod -eq 'POST') {
                    $body = Read-JsonBody $req
                    $name = [string]$body.wallpaperName
                    $idx  = [int]$body.monitorIndex
                    if ($idx -eq -1) {
                        Add-Type -AssemblyName System.Windows.Forms
                        $count = [System.Windows.Forms.Screen]::AllScreens.Count
                        $results = @()
                        for ($m = 0; $m -lt $count; $m++) {
                            $r = Invoke-Apply -MonitorIndex $m -WallpaperName $name
                            $results += @{ monitor=$m; success=$r.success; error=$r.error }
                        }
                        Send-Json $ctx @{ results = $results }
                    } else {
                        $r = Invoke-Apply -MonitorIndex $idx -WallpaperName $name
                        Send-Json $ctx @{ success=$r.success; monitor=$idx; wallpaper=$name; error=$r.error; pid=$r.pid }
                    }
                }
                elseif ($urlPath -eq '/api/stop' -and $req.HttpMethod -eq 'POST') {
                    $body = Read-JsonBody $req
                    $idx  = [int]$body.monitorIndex
                    if ($idx -eq -1) {
                        foreach ($k in @($SharedState.WallpaperProcesses.Keys)) {
                            Invoke-Stop -MonitorIndex ([int]$k)
                        }
                        Send-Json $ctx @{ success=$true; stopped='all' }
                    } else {
                        Invoke-Stop -MonitorIndex $idx
                        Send-Json $ctx @{ success=$true; monitor=$idx }
                    }
                }
                elseif ($urlPath -eq '/api/slideshow' -and $req.HttpMethod -eq 'POST') {
                    $body = Read-JsonBody $req
                    $key  = "$([int]$body.monitorIndex)"
                    $sv   = if ($SharedState.Config.Slideshow.ContainsKey($key)) {
                        $SharedState.Config.Slideshow[$key]
                    } else {
                        @{ Enabled=$false; IntervalMinutes=15; Shuffle=$false; LastChanged=[datetime]::MinValue }
                    }
                    if ($null -ne $body.enabled)         { $sv.Enabled         = [bool]$body.enabled }
                    if ($null -ne $body.intervalMinutes) { $sv.IntervalMinutes = [int]$body.intervalMinutes }
                    if ($null -ne $body.shuffle)         { $sv.Shuffle         = [bool]$body.shuffle }
                    $sv.LastChanged = [datetime]::MinValue
                    $SharedState.Config.Slideshow[$key] = $sv
                    Send-Json $ctx @{ success=$true }
                }
                else {
                    Send-Json $ctx @{ error="Not found: $urlPath" } -Code 404
                }
            } catch {
                $msg = $_.Exception.Message
                try {
                    $buf = [System.Text.Encoding]::UTF8.GetBytes("{`"error`":`"$msg`"}")
                    $ctx.Response.StatusCode      = 500
                    $ctx.Response.ContentType     = 'application/json'
                    $ctx.Response.ContentLength64 = $buf.Length
                    $ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
                } catch { }
                try { $ctx.Response.Close() } catch { }
            }
        }
    } finally {
        $listener.Stop()
        $listener.Close()
    }
}

# ============================================================================
# Public: Open-PoshWallpaper
# ============================================================================
function Open-PoshWallpaper {
    <#
    .SYNOPSIS Open the PoshWallpaper GUI manager.
    .PARAMETER NoWindow Start HTTP server only (no WebView2 window).
    .EXAMPLE Open-PoshWallpaper
    #>

    [CmdletBinding()]
    param([switch]$NoWindow)

    if ($script:SharedState -and $script:SharedState.Running) {
        Write-Host " PoshWallpaper already running on port $script:ActivePort" -ForegroundColor Yellow
        return
    }

    $dllPath = Get-WebView2DllPath
    if (-not $dllPath) { Write-Error "WebView2 not ready. Run Install-PoshDependencies."; return }

    $port = Register-PoshPort -App "PoshWallpaper"
    if (-not $port) { Write-Error "Could not allocate port."; return }
    $script:ActivePort = $port

    $script:SharedState = [hashtable]::Synchronized(@{
        Running            = $true
        Ready              = $false
        Version            = $script:Version
        WallpaperProcesses = [hashtable]::Synchronized(@{})
        Config             = Import-PoshWallpaperConfig
        DllPath            = $dllPath
        ModuleRoot         = $script:ModuleRoot
        UserWallpapersDir  = $script:UserWallpapersDir
        LauncherScript     = $script:LauncherScript
    })

    $script:ServerRunspace = [runspacefactory]::CreateRunspace()
    $script:ServerRunspace.ApartmentState = 'STA'
    $script:ServerRunspace.ThreadOptions  = 'ReuseThread'
    $script:ServerRunspace.Open()

    $script:ServerPowerShell = [PowerShell]::Create()
    $script:ServerPowerShell.Runspace = $script:ServerRunspace
    $script:ServerPowerShell.AddScript($script:ServerScript)                             | Out-Null
    $script:ServerPowerShell.AddParameter('Port',        $port)                          | Out-Null
    $script:ServerPowerShell.AddParameter('PublicPath',  $script:ModuleRoot)             | Out-Null
    $script:ServerPowerShell.AddParameter('PoshDEPath',  (Get-Module PoshDE).ModuleBase) | Out-Null
    $script:ServerPowerShell.AddParameter('SharedState', $script:SharedState)            | Out-Null
    $script:ServerAsyncResult = $script:ServerPowerShell.BeginInvoke()

    $waited = 0
    while (-not $script:SharedState.Ready -and $waited -lt 5000) {
        Start-Sleep -Milliseconds 100; $waited += 100
    }

    Write-Host ""
    Write-Host " ╔══════════════════════════════════════╗" -ForegroundColor Magenta
    Write-Host " ║ 🖼 PoshWallpaper v$script:Version ║" -ForegroundColor Magenta
    Write-Host " ╚══════════════════════════════════════╝" -ForegroundColor Magenta
    Write-Host " [*] Port $port" -ForegroundColor Yellow
    Write-Host " [*] Theme $((Get-PoshTheme).Name)" -ForegroundColor Cyan
    Write-Host " [*] Monitors $(@(Get-PoshMonitors).Count) detected" -ForegroundColor Cyan
    Write-Host ""

    if (-not $NoWindow) {
        $script:WindowProcess = New-PoshWindow -AppName "PoshWallpaper" -Port $port -Width 1280 -Height 820
        if ($script:WindowProcess) {
            Update-PoshPortPID -Port $port -PID $script:WindowProcess.Id
            Write-Host " [+] Window PID $($script:WindowProcess.Id)" -ForegroundColor Green
        }
    } else {
        Write-Host " [*] Server http://localhost:$port" -ForegroundColor Yellow
    }

    Write-Host ""
    Write-Host " Run Stop-PoshWallpaperManager to close." -ForegroundColor DarkGray
    Write-Host ""
}

# ============================================================================
# Public: Stop-PoshWallpaperManager
# ============================================================================
function Stop-PoshWallpaperManager {
    <#
    .SYNOPSIS Close the GUI manager. Wallpapers keep running unless -StopWallpapers.
    .EXAMPLE Stop-PoshWallpaperManager
    .EXAMPLE Stop-PoshWallpaperManager -StopWallpapers
    #>

    [CmdletBinding()]
    param([switch]$StopWallpapers)

    Write-Host " Stopping PoshWallpaper manager..." -ForegroundColor Yellow

    if ($StopWallpapers -and $script:SharedState) {
        foreach ($k in @($script:SharedState.WallpaperProcesses.Keys)) {
            $p = $script:SharedState.WallpaperProcesses[$k]
            if ($p -and -not $p.HasExited) { try { $p.Kill() } catch { } }
        }
    }

    if ($script:SharedState) { $script:SharedState.Running = $false }

    if ($script:WindowProcess -and -not $script:WindowProcess.HasExited) {
        $script:WindowProcess.Kill(); $script:WindowProcess.WaitForExit(3000)
    }

    if ($script:ServerPowerShell -and $script:ServerAsyncResult) {
        try { $script:ServerPowerShell.EndInvoke($script:ServerAsyncResult) | Out-Null } catch { }
        $script:ServerPowerShell.Dispose()
    }

    if ($script:ServerRunspace) { $script:ServerRunspace.Close(); $script:ServerRunspace.Dispose() }

    if ($script:ActivePort) { Unregister-PoshPort -App "PoshWallpaper" }

    $script:ActivePort        = $null
    $script:WindowProcess     = $null
    $script:ServerRunspace    = $null
    $script:ServerPowerShell  = $null
    $script:ServerAsyncResult = $null
    $script:SharedState       = $null

    Write-Host " Done." -ForegroundColor Green
}

# ============================================================================
# Public: Get-PoshWallpaperStatus
# ============================================================================
function Get-PoshWallpaperStatus {
    <#
    .SYNOPSIS Get current status of all wallpapers.
    .EXAMPLE Get-PoshWallpaperStatus
    #>

    [CmdletBinding()]
    param()
    [PSCustomObject]@{
        ManagerRunning = ($script:SharedState -and $script:SharedState.Running)
        Port           = $script:ActivePort
        Wallpapers     = if ($script:SharedState) {
            $script:SharedState.WallpaperProcesses.Keys | ForEach-Object {
                $p = $script:SharedState.WallpaperProcesses[$_]
                [PSCustomObject]@{
                    Monitor   = [int]$_
                    Wallpaper = $script:SharedState.Config.Assignments[$_]
                    Running   = ($p -and -not $p.HasExited)
                    PID       = if ($p) { $p.Id } else { $null }
                }
            }
        } else { @() }
    }
}

# ============================================================================
# Module init banner
# ============================================================================
Write-Host ""
Write-Host " PoshWallpaper v$script:Version" -ForegroundColor Magenta
Write-Host " [+] PoshDE Connected" -ForegroundColor Green
Write-Host " [*] Monitors $(@(Get-PoshMonitors).Count) detected" -ForegroundColor Cyan
Write-Host " [*] Wallpapers $(@(Get-PoshWallpapers).Count) available" -ForegroundColor Cyan
Write-Host " [*] User dir $script:UserWallpapersDir" -ForegroundColor DarkGray
Write-Host ""

# ============================================================================
# Exports
# ============================================================================
Export-ModuleMember -Function @(
    'Open-PoshWallpaper'
    'Stop-PoshWallpaperManager'
    'Start-PoshWallpaper'
    'Stop-PoshWallpaper'
    'Get-PoshWallpapers'
    'Get-PoshMonitors'
    'Get-PoshWallpaperStatus'
    'Restore-PoshWallpaperState'
)