PoshConsole.psm1

# PoshConsole - PowerShell Terminal
# Part of the PoshDE ecosystem
# https://github.com/Jakoby/PoshConsole

$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

# ============================================================================
# Status
# ============================================================================
function Get-PoshConsoleStatus {
    <#
    .SYNOPSIS
        Get the current status of PoshConsole.
    #>

    [CmdletBinding()]
    param()

    $running = $false
    if ($script:SharedState -and $script:SharedState.Ready -and $script:SharedState.Running) {
        $running = $true
    }

    [PSCustomObject]@{
        Running      = $running
        Port         = $script:ActivePort
        WindowPID    = if ($script:WindowProcess -and -not $script:WindowProcess.HasExited) { $script:WindowProcess.Id } else { $null }
        Version      = $script:Version
        WorkingDir   = if ($script:SharedState) { $script:SharedState.WorkingDirectory } else { $null }
    }
}

# ============================================================================
# Server script — runs in a background runspace
# This keeps Start-PoshConsole non-blocking so the PS7 session stays usable
# ============================================================================
$script:ServerScript = {
    param(
        [int]    $Port,
        [string] $PublicPath,
        [string] $PoshDEPath,
        [hashtable] $SharedState,
        [string] $Version,
        [string] $BannersPath,
        [string] $CustomBannerDir
    )

    # Import PoshDE into this runspace
    Import-Module $PoshDEPath -ErrorAction Stop

    # ── Persistent execution runspace ────────────────────────────────────────
    # A single shared runspace for all user commands.
    # This means cd, $variables, imported modules etc. all persist between commands.
    # CreateDefault2() loads full type/format data so Out-String works for all types.
    $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2()
    $execRunspace = [runspacefactory]::CreateRunspace($iss)
    $execRunspace.Open()

    # Set initial working directory AND inherit PSModulePath from parent session
    $initPs = [PowerShell]::Create()
    $initPs.Runspace = $execRunspace
    $initPs.AddScript({
        param($WorkingDir, $ModulePath)
        Set-Location -LiteralPath $WorkingDir
        $env:PSModulePath = $ModulePath
    }) | Out-Null
    $initPs.AddArgument($SharedState.InitialWorkingDir) | Out-Null
    $initPs.AddArgument($env:PSModulePath) | Out-Null
    $initPs.Invoke() | Out-Null
    $initPs.Dispose()

    $commandHistory = [System.Collections.Generic.List[string]]::new()

    $contentTypes = @{
        '.html' = 'text/html; charset=utf-8'
        '.css'  = 'text/css'
        '.js'   = 'application/javascript'
        '.json' = 'application/json'
        '.png'  = 'image/png'
        '.svg'  = 'image/svg+xml'
        '.ico'  = 'image/x-icon'
        '.woff2'= 'font/woff2'
    }

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

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

    # ── Banner helpers ───────────────────────────────────────────────────────
    function Get-BannerList {
        $banners = [System.Collections.Generic.List[object]]::new()

        # Built-in banners
        if (Test-Path $BannersPath) {
            foreach ($f in Get-ChildItem $BannersPath -Filter '*.banner' | Sort-Object Name) {
                try {
                    $data = Get-Content $f.FullName -Raw | ConvertFrom-Json
                    $banners.Add(@{
                        name        = $data.name
                        label       = $data.label
                        description = $data.description
                        source      = 'builtin'
                    })
                } catch { }
            }
        }

        # Custom banners
        if ($CustomBannerDir -and (Test-Path $CustomBannerDir)) {
            foreach ($f in Get-ChildItem $CustomBannerDir -Filter '*.banner' | Sort-Object Name) {
                try {
                    $data = Get-Content $f.FullName -Raw | ConvertFrom-Json
                    # Custom banner with same name overrides built-in in the list
                    $existing = $banners | Where-Object { $_.name -eq $data.name }
                    if ($existing) { $banners.Remove($existing) | Out-Null }
                    $banners.Add(@{
                        name        = $data.name
                        label       = $data.label
                        description = $data.description
                        source      = 'custom'
                    })
                } catch { }
            }
        }

        return @($banners)
    }

    function Get-BannerContent {
        param([string]$Name)
        # Custom dir takes precedence over built-in
        $searchDirs = @()
        if ($CustomBannerDir -and (Test-Path $CustomBannerDir)) { $searchDirs += $CustomBannerDir }
        if (Test-Path $BannersPath) { $searchDirs += $BannersPath }

        foreach ($dir in $searchDirs) {
            $file = Join-Path $dir "$Name.banner"
            if (Test-Path $file) {
                try { return Get-Content $file -Raw | ConvertFrom-Json }
                catch { }
            }
        }
        return $null
    }

    function Get-WorkingDir {
        $ps = [PowerShell]::Create()
        $ps.Runspace = $execRunspace
        $ps.AddScript('$PWD.Path') | Out-Null
        $result = $ps.Invoke()
        $ps.Dispose()
        return $(if ($result -and $result[0]) { $result[0].ToString() } else { $SharedState.WorkingDirectory })
    }

    # ── Start 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) {

            # GetContextAsync + Wait(500ms) lets us check the Running flag
            # without blocking forever on an idle server
            $task = $listener.GetContextAsync()
            while (-not $task.Wait(500)) {
                if (-not $SharedState.Running) { break }
            }
            if (-not $SharedState.Running) { break }
            if ($task.IsFaulted -or $task.IsCanceled) { continue }

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

            # CORS headers on every response
            $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 {
                # OPTIONS preflight
                if ($req.HttpMethod -eq 'OPTIONS') {
                    $ctx.Response.StatusCode = 200
                    $ctx.Response.Close()
                    continue
                }

                # ── /api/theme ────────────────────────────────────────────
                if ($urlPath -eq '/api/theme' -and $req.HttpMethod -eq 'GET') {
                    $theme = Get-PoshTheme
                    Send-Json $ctx @{
                        name   = $theme.name
                        css    = Export-PoshThemeCSS
                        colors = $theme.colors
                    }
                }

                # ── /api/banners (list) ──────────────────────────────────
                elseif ($urlPath -eq '/api/banners' -and $req.HttpMethod -eq 'GET') {
                    Send-Json $ctx @{ banners = @(Get-BannerList) }
                }

                # ── /api/banner/{name} (content) ──────────────────────────
                elseif ($urlPath -match '^/api/banner/(.+)$' -and $req.HttpMethod -eq 'GET') {
                    $bannerName = $Matches[1]
                    $banner = Get-BannerContent -Name $bannerName
                    if ($banner) {
                        Send-Json $ctx @{
                            name = $banner.name
                            html = $banner.html
                            css  = if ($banner.css) { $banner.css } else { '' }
                            js   = if ($banner.js)  { $banner.js  } else { '' }
                        }
                    } else {
                        $buf = [System.Text.Encoding]::UTF8.GetBytes("{`"error`":`"Banner not found`"}")
                        $ctx.Response.StatusCode      = 404
                        $ctx.Response.ContentType     = 'application/json'
                        $ctx.Response.ContentLength64 = $buf.Length
                        $ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
                        $ctx.Response.Close()
                    }
                }

                # ── /api/execute ──────────────────────────────────────────
                elseif ($urlPath -eq '/api/execute' -and $req.HttpMethod -eq 'POST') {
                    $body    = Read-JsonBody $req
                    $command = $body.command

                    if ($command -and $command.Trim()) {
                        $commandHistory.Add($command)
                    }

                    $result = @{
                        output     = ''
                        error      = ''
                        richOutput = $null
                        workingDir = $SharedState.WorkingDirectory
                        exitCode   = 0
                    }

                    $ps  = $null
                    $ps2 = $null
                    try {
                        # Step 1: run the command in the exec runspace, collect raw objects
                        $ps = [PowerShell]::Create()
                        $ps.Runspace = $execRunspace
                        $ps.AddScript($command) | Out-Null
                        $rawOutput = $ps.Invoke()

                        $lines = [System.Collections.Generic.List[string]]::new()

                        # Capture output streams from step 1
                        foreach ($info in $ps.Streams.Information) { $lines.Add($info.MessageData.ToString()) }
                        foreach ($warn in $ps.Streams.Warning)     { $lines.Add("WARNING: $($warn.Message)") }
                        foreach ($dbg  in $ps.Streams.Debug)       { $lines.Add("DEBUG: $($dbg.Message)") }
                        foreach ($err  in $ps.Streams.Error) {
                            $lines.Add("ERROR: $($err.Exception.Message)")
                            $result.exitCode = 1
                        }

                        # Step 2: format raw objects via Out-String — must run in exec runspace
                        # where CreateDefault2() loaded the format/type XML data
                        if ($rawOutput -and $rawOutput.Count -gt 0) {
                            $ps2 = [PowerShell]::Create()
                            $ps2.Runspace = $execRunspace
                            $ps2.AddScript({ param($items) $items | Out-String -Width 500 }) | Out-Null
                            $ps2.AddArgument($rawOutput) | Out-Null
                            $formatted = $ps2.Invoke()
                            $text = ($formatted -join "`n").TrimEnd()
                            if ($text) { $lines.Add($text) }
                        }

                        $result.output = $lines -join "`n"

                    } catch {
                        $result.error    = $_.Exception.Message
                        $result.exitCode = 1
                    } finally {
                        if ($ps)  { $ps.Dispose() }
                        if ($ps2) { $ps2.Dispose() }
                    }

                    # Sync working directory — isolated so it never contaminates the result
                    try {
                        $wd = Get-WorkingDir
                        $SharedState.WorkingDirectory = $wd
                        $result.workingDir = $wd
                    } catch {
                        $result.workingDir = $SharedState.WorkingDirectory
                    }

                    Send-Json $ctx $result
                }

                # ── /api/history ──────────────────────────────────────────
                elseif ($urlPath -eq '/api/history' -and $req.HttpMethod -eq 'GET') {
                    Send-Json $ctx @{
                        history = @($commandHistory)
                        count   = $commandHistory.Count
                    }
                }

                # ── /api/history/clear ────────────────────────────────────
                elseif ($urlPath -eq '/api/history/clear' -and $req.HttpMethod -eq 'POST') {
                    $commandHistory.Clear()
                    Send-Json $ctx @{ success = $true }
                }

                # ── /api/completions ──────────────────────────────────────
                elseif ($urlPath -eq '/api/completions' -and $req.HttpMethod -eq 'POST') {
                    $body        = Read-JsonBody $req
                    $completions = @()
                    $replIndex   = 0
                    $replLength  = 0

                    try {
                        $cr = [System.Management.Automation.CommandCompletion]::CompleteInput(
                            $body.input, [int]$body.cursorPosition, $null
                        )
                        $completions = @($cr.CompletionMatches | ForEach-Object {
                            @{ text = $_.CompletionText; type = $_.ResultType.ToString(); tooltip = $_.ToolTip }
                        })
                        $replIndex  = $cr.ReplacementIndex
                        $replLength = $cr.ReplacementLength
                    } catch { }

                    Send-Json $ctx @{
                        completions       = $completions
                        replacementIndex  = $replIndex
                        replacementLength = $replLength
                    }
                }

                # ── /api/info ─────────────────────────────────────────────
                elseif ($urlPath -eq '/api/info' -and $req.HttpMethod -eq 'GET') {
                    # Get PS version from the execution runspace (reflects what the user actually runs)
                    $pvPs = [PowerShell]::Create()
                    $pvPs.Runspace = $execRunspace
                    $pvPs.AddScript('$PSVersionTable.PSVersion.ToString()') | Out-Null
                    $psVer = $pvPs.Invoke()
                    $pvPs.Dispose()

                    Send-Json $ctx @{
                        version    = $Version
                        psVersion  = if ($psVer -and $psVer[0]) { $psVer[0].ToString() } else { 'Unknown' }
                        os         = [System.Environment]::OSVersion.VersionString
                        user       = $env:USERNAME
                        hostname   = $env:COMPUTERNAME
                        workingDir = $SharedState.WorkingDirectory
                    }
                }

                # ── Static files ──────────────────────────────────────────
                else {
                    if ($urlPath -eq '/') { $urlPath = '/index.html' }
                    $filePath = Join-Path $PublicPath $urlPath.TrimStart('/')

                    if (Test-Path $filePath -PathType Leaf) {
                        $ext         = [System.IO.Path]::GetExtension($filePath).ToLower()
                        $contentType = if ($contentTypes[$ext]) { $contentTypes[$ext] } else { 'application/octet-stream' }
                        $bytes       = [System.IO.File]::ReadAllBytes($filePath)

                        $ctx.Response.ContentType     = $contentType
                        $ctx.Response.ContentLength64 = $bytes.Length
                        $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
                        $ctx.Response.Close()
                    } 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()
                    }
                }

            } catch {
                # Last-resort error response
                try {
                    $buf = [System.Text.Encoding]::UTF8.GetBytes("{`"error`":`"$($_.Exception.Message)`"}")
                    $ctx.Response.StatusCode      = 500
                    $ctx.Response.ContentType     = 'application/json'
                    $ctx.Response.ContentLength64 = $buf.Length
                    $ctx.Response.OutputStream.Write($buf, 0, $buf.Length)
                    $ctx.Response.Close()
                } catch { }
            }
        }
    } finally {
        $listener.Stop()
        $listener.Close()
        $execRunspace.Close()
        $execRunspace.Dispose()
    }
}

# ============================================================================
# Start
# ============================================================================
function Start-PoshConsole {
    <#
    .SYNOPSIS
        Start PoshConsole — a WebView2 terminal powered by PoshDE.
    .DESCRIPTION
        Starts the HTTP server in a background runspace (non-blocking) and
        launches a WebView2 window via PoshDE's shared New-PoshWindow function.
        Your PS7 session stays free while PoshConsole runs.
    .PARAMETER NoWindow
        Start the server without opening the WebView2 window.
        Useful for headless use or debugging.
    .EXAMPLE
        Start-PoshConsole
    .EXAMPLE
        Start-PoshConsole -NoWindow
    #>

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

    # ── Preflight checks ─────────────────────────────────────────────────────
    if (-not (Get-Module -Name PoshDE)) {
        Write-Error "PoshDE is required. Run: Install-Module PoshDE"
        return
    }

    if (-not (Test-PoshDependencies)) {
        Write-Error "WebView2 is not installed. Run: Install-PoshDependencies"
        return
    }

    if ($script:SharedState -and $script:SharedState.Running) {
        Write-Warning "PoshConsole is already running on port $script:ActivePort"
        return
    }

    $publicPath = Join-Path $script:ModuleRoot "Public"
    if (-not (Test-Path $publicPath)) {
        Write-Error "Public folder not found at: $publicPath"
        return
    }

    # ── Port allocation ───────────────────────────────────────────────────────
    $port = $null
    for ($attempt = 1; $attempt -le 5; $attempt++) {
        $candidate = Register-PoshPort -App "PoshConsole"
        if (-not $candidate) {
            Write-Error "PoshDE could not allocate a port."
            return
        }

        # Verify the port is actually bindable
        try {
            $test = New-Object System.Net.HttpListener
            $test.Prefixes.Add("http://localhost:$candidate/")
            $test.Start()
            $test.Stop()
            $test.Close()
            $port = $candidate
            break
        } catch {
            Unregister-PoshPort -Port $candidate
            Start-Sleep -Milliseconds 300
        } finally {
            try { $test.Close() } catch {}
        }
    }

    if (-not $port) {
        Write-Error "Could not bind to any port after 5 attempts."
        return
    }

    $script:ActivePort = $port

    # ── Load PoshConsole config (custom banner dir etc.) ─────────────────────
    $script:ConfigPath = Join-Path $env:APPDATA "PoshDE\PoshConsole"
    if (-not (Test-Path $script:ConfigPath)) {
        New-Item -Path $script:ConfigPath -ItemType Directory -Force | Out-Null
    }
    $script:ConfigFile   = Join-Path $script:ConfigPath "config.json"
    $script:ConsoleConfig = @{ CustomBannerDir = '' }
    if (Test-Path $script:ConfigFile) {
        try {
            $saved = Get-Content $script:ConfigFile -Raw | ConvertFrom-Json
            if ($saved.CustomBannerDir) { $script:ConsoleConfig.CustomBannerDir = $saved.CustomBannerDir }
        } catch { }
    }

    # ── Shared state (thread-safe hashtable) ─────────────────────────────────
    $script:SharedState = [hashtable]::Synchronized(@{
        Running              = $true
        Ready                = $false
        WorkingDirectory     = $PWD.Path
        InitialWorkingDir    = $PWD.Path
    })

    # ── Launch server runspace ────────────────────────────────────────────────
    $poshDEPath = (Get-Module PoshDE).ModuleBase

    $script:ServerRunspace   = [runspacefactory]::CreateRunspace()
    $script:ServerRunspace.Open()

    $script:ServerPowerShell = [PowerShell]::Create()
    $script:ServerPowerShell.Runspace = $script:ServerRunspace
    $script:ServerPowerShell.AddScript($script:ServerScript) | Out-Null
    $script:ServerPowerShell.AddArgument($port)        | Out-Null
    $script:ServerPowerShell.AddArgument($publicPath)  | Out-Null
    $script:ServerPowerShell.AddArgument($poshDEPath)  | Out-Null
    $script:ServerPowerShell.AddArgument($script:SharedState)                     | Out-Null
    $script:ServerPowerShell.AddArgument($script:Version)                         | Out-Null
    $script:ServerPowerShell.AddArgument((Join-Path $script:ModuleRoot 'Banners')) | Out-Null
    $script:ServerPowerShell.AddArgument($script:ConsoleConfig.CustomBannerDir)   | Out-Null

    $script:ServerAsyncResult = $script:ServerPowerShell.BeginInvoke()

    # Wait for the server to signal ready (up to 10s)
    $waited = 0
    while (-not $script:SharedState.Ready -and $waited -lt 10000) {
        Start-Sleep -Milliseconds 100
        $waited += 100
    }

    if (-not $script:SharedState.Ready) {
        Write-Warning "Server did not signal ready within 10 seconds."
    }

    # ── Banner ────────────────────────────────────────────────────────────────
    Write-Host ""
    Write-Host " ╔══════════════════════════════════════╗" -ForegroundColor Cyan
    Write-Host " ║ 💻 PoshConsole v$script:Version ║" -ForegroundColor Cyan
    Write-Host " ╚══════════════════════════════════════╝" -ForegroundColor Cyan
    Write-Host ""
    Write-Host " [*] Port " -NoNewline -ForegroundColor DarkGray
    Write-Host $port -ForegroundColor Yellow
    Write-Host " [*] Theme " -NoNewline -ForegroundColor DarkGray
    Write-Host (Get-PoshTheme).name -ForegroundColor Cyan
    Write-Host " [*] Directory " -NoNewline -ForegroundColor DarkGray
    Write-Host $PWD.Path -ForegroundColor DarkGray
    if ($script:ConsoleConfig.CustomBannerDir) {
        Write-Host " [*] Banners " -NoNewline -ForegroundColor DarkGray
        Write-Host $script:ConsoleConfig.CustomBannerDir -ForegroundColor DarkGray
    }
    Write-Host ""

    # ── Launch WebView2 window ────────────────────────────────────────────────
    if (-not $NoWindow) {
        $script:WindowProcess = New-PoshWindow -AppName "PoshConsole" -Port $port -Width 1100 -Height 750
        if ($script:WindowProcess) {
            Update-PoshPortPID -Port $port -PID $script:WindowProcess.Id
            Write-Host " [+] Window " -NoNewline -ForegroundColor Green
            Write-Host "PID $($script:WindowProcess.Id)" -ForegroundColor Cyan
        } else {
            Write-Warning "WebView2 window failed to launch."
        }
    } else {
        Write-Host " [*] Server " -NoNewline -ForegroundColor DarkGray
        Write-Host "http://localhost:$port" -ForegroundColor Yellow
    }

    Write-Host ""
    Write-Host " Run " -NoNewline -ForegroundColor DarkGray
    Write-Host "Stop-PoshConsole" -NoNewline -ForegroundColor Cyan
    Write-Host " to close." -ForegroundColor DarkGray
    Write-Host ""
}

# ============================================================================
# Stop
# ============================================================================
function Stop-PoshConsole {
    <#
    .SYNOPSIS
        Stop PoshConsole and release all resources.
    #>

    [CmdletBinding()]
    param()

    Write-Host ""
    Write-Host " Stopping PoshConsole..." -ForegroundColor Yellow

    # Signal the server loop to exit
    if ($script:SharedState) {
        $script:SharedState.Running = $false
    }

    # Kill the WebView2 window
    if ($script:WindowProcess -and -not $script:WindowProcess.HasExited) {
        $script:WindowProcess.Kill()
        $script:WindowProcess.WaitForExit(3000)
    }

    # Wait for server runspace to finish, then clean up
    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()
    }

    # Release the port
    if ($script:ActivePort) {
        Unregister-PoshPort -App "PoshConsole"
    }

    # Clear state
    $script:ActivePort        = $null
    $script:WindowProcess     = $null
    $script:ServerRunspace    = $null
    $script:ServerPowerShell  = $null
    $script:ServerAsyncResult = $null
    $script:SharedState       = $null

    Write-Host " Done." -ForegroundColor Green
    Write-Host ""
}

# ============================================================================
# Banner directory configuration
# ============================================================================
function Set-PoshConsoleBannerDir {
    <#
    .SYNOPSIS
        Set a custom directory for PoshConsole to load .banner files from.
    .DESCRIPTION
        The custom directory is stored outside the module folder so it survives
        module updates. Files in the custom directory take precedence over
        built-in banners with the same name.

        Pass no path (or -Clear) to remove the custom directory setting.
    .PARAMETER Path
        Full path to the directory containing your .banner files.
    .PARAMETER Clear
        Remove the custom banner directory setting.
    .EXAMPLE
        Set-PoshConsoleBannerDir -Path "C:\MyBanners"
    .EXAMPLE
        Set-PoshConsoleBannerDir -Clear
    #>

    [CmdletBinding()]
    param(
        [Parameter(ParameterSetName = 'Set')]
        [string]$Path,

        [Parameter(ParameterSetName = 'Clear')]
        [switch]$Clear
    )

    $configDir  = Join-Path $env:APPDATA "PoshDE\PoshConsole"
    $configFile = Join-Path $configDir "config.json"

    if (-not (Test-Path $configDir)) {
        New-Item -Path $configDir -ItemType Directory -Force | Out-Null
    }

    # Load existing config
    $config = @{ CustomBannerDir = '' }
    if (Test-Path $configFile) {
        try {
            $saved = Get-Content $configFile -Raw | ConvertFrom-Json
            if ($saved.CustomBannerDir) { $config.CustomBannerDir = $saved.CustomBannerDir }
        } catch { }
    }

    if ($Clear) {
        $config.CustomBannerDir = ''
        $config | ConvertTo-Json | Set-Content $configFile -Encoding UTF8
        Write-Host " Custom banner directory cleared." -ForegroundColor Yellow
        Write-Host " Restart PoshConsole to apply." -ForegroundColor DarkGray
        return
    }

    if (-not $Path) {
        Write-Host " Current custom banner dir: " -NoNewline -ForegroundColor DarkGray
        if ($config.CustomBannerDir) {
            Write-Host $config.CustomBannerDir -ForegroundColor Cyan
        } else {
            Write-Host "(none)" -ForegroundColor DarkGray
        }
        return
    }

    if (-not (Test-Path $Path -PathType Container)) {
        Write-Error "Directory not found: $Path"
        return
    }

    $bannerCount = @(Get-ChildItem $Path -Filter '*.banner' -ErrorAction SilentlyContinue).Count
    $config.CustomBannerDir = $Path
    $config | ConvertTo-Json | Set-Content $configFile -Encoding UTF8

    Write-Host " Custom banner dir set to: " -NoNewline -ForegroundColor DarkGray
    Write-Host $Path -ForegroundColor Cyan
    Write-Host " Found $bannerCount .banner file(s)." -ForegroundColor DarkGray
    Write-Host " Restart PoshConsole to apply." -ForegroundColor DarkGray
}

# ============================================================================
# Module init banner
# ============================================================================
Write-Host ""
Write-Host " PoshConsole v$script:Version" -ForegroundColor Cyan

if (Get-Module -Name PoshDE) {
    Write-Host " [+] PoshDE " -NoNewline -ForegroundColor Green
    Write-Host "Connected" -ForegroundColor Cyan
} else {
    Write-Host " [-] PoshDE " -NoNewline -ForegroundColor Red
    Write-Host "Not found — run: Install-Module PoshDE" -ForegroundColor Yellow
}
Write-Host ""

# ============================================================================
# Exports
# ============================================================================
Export-ModuleMember -Function @(
    'Start-PoshConsole'
    'Stop-PoshConsole'
    'Get-PoshConsoleStatus'
    'Set-PoshConsoleBannerDir'
)