# write_server.ps1
# File write service - listens on port 23456
# 除了原有的整份覆写文件(POST /?file=path)之外, 还给 index.html 左侧文件管理器提供
# list/mkdir/newFile/delete/restore 这几个 action(POST 或 GET /?action=xxx)

$port    = 23456
$baseDir = Split-Path -Parent $PSScriptRoot

$protectedTemplates = @('4.0c.html')
$reservedTopDirs    = @('js', 'geogebra')
$trashDir           = Join-Path $baseDir 'js\trash'
$manifestPath       = Join-Path $trashDir 'manifest.json'

# ---------- 路径安全 ----------
function Resolve-SafePath {
    param([string]$RelPath)
    if ([string]::IsNullOrEmpty($RelPath) -or $RelPath.StartsWith('/') -or $RelPath.StartsWith('\') -or $RelPath.Contains(':')) {
        throw "非法路径"
    }
    $top = $RelPath.Split('/')[0]
    if ($reservedTopDirs -contains $top) {
        throw "该目录由系统保留, 不能操作"
    }
    $localRel  = $RelPath -replace '/', '\'
    $rootFull  = [System.IO.Path]::GetFullPath($baseDir)
    $target    = [System.IO.Path]::GetFullPath((Join-Path $baseDir $localRel))
    if ($target -ne $rootFull -and -not $target.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar)) {
        throw "非法路径"
    }
    return $target
}

function Get-RelPath {
    param([string]$AbsPath)
    $rootFull = [System.IO.Path]::GetFullPath($baseDir)
    $rel = $AbsPath.Substring($rootFull.Length).TrimStart('\', '/')
    return ($rel -replace '\\', '/')
}

# ---------- 手写 JSON 序列化(避免 ConvertTo-Json 对 0/1 元素数组的坑) ----------
function ConvertTo-JsonStringLiteral {
    param($Value)
    if ($null -eq $Value) { return '""' }
    $s = [string]$Value
    $s = $s -replace '\\', '\\\\'
    $s = $s -replace '"', '\"'
    $s = $s -replace "`r", '\r'
    $s = $s -replace "`n", '\n'
    $s = $s -replace "`t", '\t'
    return '"' + $s + '"'
}

function ConvertTo-TreeJson {
    param($Entries)
    $items = @()
    foreach ($e in $Entries) {
        if ($e.isDir) {
            $items += ('{{"path":{0},"name":{1},"isDir":true,"children":{2}}}' -f `
                (ConvertTo-JsonStringLiteral $e.path), (ConvertTo-JsonStringLiteral $e.name), (ConvertTo-TreeJson $e.children))
        } else {
            $items += ('{{"path":{0},"name":{1},"isDir":false}}' -f `
                (ConvertTo-JsonStringLiteral $e.path), (ConvertTo-JsonStringLiteral $e.name))
        }
    }
    return '[' + ($items -join ',') + ']'
}

function ConvertTo-TrashJson {
    param($Entries)
    $items = @()
    foreach ($e in $Entries) {
        $isDirLit = if ($e.isDir) { 'true' } else { 'false' }
        $items += ('{{"id":{0},"originalPath":{1},"isDir":{2},"deletedAt":{3}}}' -f `
            (ConvertTo-JsonStringLiteral $e.id), (ConvertTo-JsonStringLiteral $e.originalPath), $isDirLit, [int64]$e.deletedAt)
    }
    return '[' + ($items -join ',') + ']'
}

# ---------- 目录树 / 回收站清单 ----------
function Build-Tree {
    param([string]$DirAbs)
    # 注意: 这里用 `,$result` 强制把整个数组当成单个管道对象返回, 调用方(包括这里的递归调用自己)
    # 绝对不能再用 @(...) 包一层——那样会把这个"单个对象"又包成一层数组, 导致
    # foreach 里的 $e 变成"内层那个真正的数组", 对它取 .path/.name 时 PowerShell 会把
    # 每个元素的属性收集成一个数组、[string] 强转时又用空格拼接, 最终整个目录下所有
    # 兄弟文件/文件夹的名字被拼成一坨塞进同一个 JSON 节点。之前这里就是这么写挂的。
    $result = @()
    $items = Get-ChildItem -LiteralPath $DirAbs -Force | Sort-Object Name
    foreach ($item in $items) {
        $rel = Get-RelPath $item.FullName
        if (($reservedTopDirs -contains $rel) -or ($rel -eq 'index.html')) { continue }
        if ($item.PSIsContainer) {
            $children = Build-Tree $item.FullName
            $result += [PSCustomObject]@{ path = $rel; name = $item.Name; isDir = $true; children = $children }
        } elseif ($item.Extension -ieq '.html') {
            $result += [PSCustomObject]@{ path = $rel; name = $item.Name; isDir = $false }
        }
    }
    return ,$result
}

function Get-TrashManifest {
    if (-not (Test-Path $manifestPath)) { return @() }
    try {
        $raw = Get-Content -Raw -Path $manifestPath -Encoding UTF8
        if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
        $parsed = $raw | ConvertFrom-Json
        if ($null -eq $parsed) { return @() }
        return @($parsed)
    } catch {
        return @()
    }
}

function Save-TrashManifest {
    param($Entries)
    if (-not (Test-Path $trashDir)) { New-Item -ItemType Directory -Path $trashDir -Force | Out-Null }
    [System.IO.File]::WriteAllText($manifestPath, (ConvertTo-TrashJson $Entries), [System.Text.Encoding]::UTF8)
}

# ---------- action 处理 ----------
function Invoke-ListAction {
    $tree  = Build-Tree $baseDir
    $trash = @(Get-TrashManifest)
    return '{{"success":true,"tree":{0},"trash":{1}}}' -f (ConvertTo-TreeJson $tree), (ConvertTo-TrashJson $trash)
}

function Invoke-MkdirAction {
    param($Payload)
    $rel = [string]$Payload.path
    $target = Resolve-SafePath $rel
    if (Test-Path $target) { return '{"success":false,"error":"目标已存在"}' }
    New-Item -ItemType Directory -Path $target -Force | Out-Null
    return '{"success":true}'
}

function Invoke-NewFileAction {
    param($Payload)
    $rel = [string]$Payload.path
    if ($rel -notmatch '(?i)\.html$') { return '{"success":false,"error":"文件名必须以 .html 结尾"}' }
    $target = Resolve-SafePath $rel
    if (Test-Path $target) { return '{"success":false,"error":"目标已存在"}' }
    $template = Join-Path $baseDir '4.0c.html'
    if (-not (Test-Path $template)) { return '{"success":false,"error":"模板文件 4.0c.html 不存在"}' }
    $parent = Split-Path -Parent $target
    if ($parent -and -not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
    Copy-Item -LiteralPath $template -Destination $target
    return '{"success":true}'
}

function Invoke-DeleteAction {
    param($Payload)
    $rel = [string]$Payload.path
    if ($protectedTemplates -contains $rel) { return '{"success":false,"error":"该文件是内置模板, 不能删除"}' }
    $target = Resolve-SafePath $rel
    if (-not (Test-Path $target)) { return '{"success":false,"error":"目标不存在"}' }
    $trashId  = [guid]::NewGuid().ToString("N").Substring(0, 12)
    $basename = Split-Path -Leaf $target
    if (-not (Test-Path $trashDir)) { New-Item -ItemType Directory -Path $trashDir -Force | Out-Null }
    $isDir = (Get-Item -LiteralPath $target).PSIsContainer
    $dest  = Join-Path $trashDir ("{0}__{1}" -f $trashId, $basename)
    Move-Item -LiteralPath $target -Destination $dest
    $manifest = @(Get-TrashManifest)
    $manifest += [PSCustomObject]@{
        id = $trashId; originalPath = $rel; isDir = $isDir
        deletedAt = [int64]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
    }
    Save-TrashManifest $manifest
    return '{{"success":true,"id":{0}}}' -f (ConvertTo-JsonStringLiteral $trashId)
}

function Invoke-RestoreAction {
    param($Payload)
    $trashId  = [string]$Payload.id
    $manifest = @(Get-TrashManifest)
    $entry = $manifest | Where-Object { $_.id -eq $trashId } | Select-Object -First 1
    if (-not $entry) { return '{"success":false,"error":"回收站记录不存在"}' }
    $originalAbs = Resolve-SafePath $entry.originalPath
    if (Test-Path $originalAbs) { return '{"success":false,"error":"原路径已被占用, 无法还原"}' }
    $basename = Split-Path -Leaf $originalAbs
    $trashSrc = Join-Path $trashDir ("{0}__{1}" -f $trashId, $basename)
    if (-not (Test-Path $trashSrc)) { return '{"success":false,"error":"回收站文件缺失"}' }
    $parent = Split-Path -Parent $originalAbs
    if ($parent -and -not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
    Move-Item -LiteralPath $trashSrc -Destination $originalAbs
    $manifest = @($manifest | Where-Object { $_.id -ne $trashId })
    Save-TrashManifest $manifest
    return '{"success":true}'
}

function Invoke-RenameAction {
    param($Payload)
    $rel     = [string]$Payload.path
    $newName = [string]$Payload.newName
    if ($protectedTemplates -contains $rel) { return '{"success":false,"error":"该文件是内置模板, 不能重命名"}' }
    if ([string]::IsNullOrWhiteSpace($newName) -or $newName -eq '.' -or $newName -eq '..' -or $newName.Contains('/') -or $newName.Contains('\') -or $newName.Contains(':')) {
        return '{"success":false,"error":"新名称不合法"}'
    }
    $target = Resolve-SafePath $rel
    if (-not (Test-Path $target)) { return '{"success":false,"error":"目标不存在"}' }
    $isDir = (Get-Item -LiteralPath $target).PSIsContainer
    if ((-not $isDir) -and ($newName -notmatch '(?i)\.html$')) { return '{"success":false,"error":"文件名必须以 .html 结尾"}' }
    $parent    = Split-Path -Parent $target
    $newTarget = Join-Path $parent $newName
    if (Test-Path $newTarget) { return '{"success":false,"error":"目标名称已存在"}' }
    Rename-Item -LiteralPath $target -NewName $newName
    return '{"success":true}'
}

function Get-UniqueCopyName {
    param([string]$DirAbs, [string]$Base, [string]$Ext)
    $candidate = "${Base}_copy${Ext}"
    $n = 2
    while (Test-Path (Join-Path $DirAbs $candidate)) {
        $candidate = "${Base}_copy${n}${Ext}"
        $n++
    }
    return $candidate
}

function Invoke-DuplicateAction {
    param($Payload)
    $rel    = [string]$Payload.path
    $target = Resolve-SafePath $rel
    if (-not (Test-Path $target)) { return '{"success":false,"error":"目标不存在"}' }
    $parent = Split-Path -Parent $target
    $isDir  = (Get-Item -LiteralPath $target).PSIsContainer
    if ($isDir) {
        $newName = Get-UniqueCopyName $parent (Split-Path -Leaf $target) ''
        Copy-Item -LiteralPath $target -Destination (Join-Path $parent $newName) -Recurse
    } else {
        $base    = [System.IO.Path]::GetFileNameWithoutExtension($target)
        $ext     = [System.IO.Path]::GetExtension($target)
        $newName = Get-UniqueCopyName $parent $base $ext
        Copy-Item -LiteralPath $target -Destination (Join-Path $parent $newName)
    }
    return ('{{"success":true,"name":{0}}}' -f (ConvertTo-JsonStringLiteral $newName))
}

function Invoke-MoveAction {
    param($Payload)
    $rel          = [string]$Payload.path
    $targetDirRel = [string]$Payload.targetDir
    if ($protectedTemplates -contains $rel) { return '{"success":false,"error":"该文件是内置模板, 不能移动"}' }
    $target = Resolve-SafePath $rel
    if (-not (Test-Path $target)) { return '{"success":false,"error":"目标不存在"}' }
    if ([string]::IsNullOrEmpty($targetDirRel)) {
        $targetDirAbs = [System.IO.Path]::GetFullPath($baseDir)
    } else {
        $targetDirAbs = Resolve-SafePath $targetDirRel
    }
    if (-not (Test-Path $targetDirAbs -PathType Container)) { return '{"success":false,"error":"目标文件夹不存在"}' }
    $isDir = (Get-Item -LiteralPath $target).PSIsContainer
    if ($isDir -and (($targetDirAbs -eq $target) -or ($targetDirAbs.StartsWith($target + [System.IO.Path]::DirectorySeparatorChar)))) {
        return '{"success":false,"error":"不能移动到自身或子文件夹中"}'
    }
    $newTarget = Join-Path $targetDirAbs (Split-Path -Leaf $target)
    if ($newTarget -eq $target) { return '{"success":false,"error":"目标位置与原位置相同"}' }
    if (Test-Path $newTarget) { return '{"success":false,"error":"目标位置已存在同名文件/文件夹"}' }
    Move-Item -LiteralPath $target -Destination $newTarget
    return '{"success":true}'
}

function Invoke-Action {
    param([string]$Action, $Payload)
    try {
        switch ($Action) {
            'list'    { return Invoke-ListAction }
            'mkdir'   { return Invoke-MkdirAction $Payload }
            'newFile' { return Invoke-NewFileAction $Payload }
            'delete'  { return Invoke-DeleteAction $Payload }
            'restore' { return Invoke-RestoreAction $Payload }
            'rename'    { return Invoke-RenameAction $Payload }
            'duplicate' { return Invoke-DuplicateAction $Payload }
            'move'      { return Invoke-MoveAction $Payload }
            default   { return '{"success":false,"error":"未知操作"}' }
        }
    } catch {
        $msg = $_.Exception.Message -replace '\\', '\\\\' -replace '"', '\"'
        return '{{"success":false,"error":"{0}"}}' -f $msg
    }
}

# ---------- HTTP 服务 ----------
# 无论 启动.bat(有可见窗口) 还是 启动.vbs(全隐藏、什么都看不到) 启动, 只要这里失败就把原因写进
# js\write_server_error.log, 这样不管用哪种方式启动, 事后都能翻这个文件看到底为什么连不上,
# 不用非得盯着一闪而过的/根本看不到的控制台窗口。
$errorLogPath = Join-Path $baseDir 'js\write_server_error.log'
function Write-StartupError {
    param([string]$Message)
    Write-Host $Message -ForegroundColor Red
    try {
        $trashDirForLog = Split-Path -Parent $errorLogPath
        if (-not (Test-Path $trashDirForLog)) { New-Item -ItemType Directory -Path $trashDirForLog -Force | Out-Null }
        "[$([DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))] $Message" | Out-File -FilePath $errorLogPath -Encoding utf8 -Append
    } catch { }
}

$inUse = netstat -ano | Select-String ":$port "
if ($inUse) {
    Write-StartupError "write_server 未启动: 端口 $port 已被占用(可能是上一次没关掉的旧进程, 或者别的程序占用了这个端口)。请检查 `"netstat -ano | findstr :$port`" 并结束占用它的进程后重试。"
    exit 0
}

try {
    $listener = New-Object System.Net.HttpListener
    $listener.Prefixes.Add("http://localhost:$port/")
    $listener.Start()
} catch {
    Write-StartupError "write_server 启动失败(监听 http://localhost:$port/ 时出错): $($_.Exception.GetType().FullName): $($_.Exception.Message)"
    Write-StartupError "常见原因: 1) Windows 防火墙拦截了 powershell.exe 但没有弹出可见的允许提示(尤其是通过 启动.vbs 隐藏窗口启动时); 2) 系统策略要求管理员权限才能监听端口, 可尝试以管理员身份运行, 或执行一次 `"netsh http add urlacl url=http://localhost:$port/ user=Everyone`"; 3) 杀毒软件/安全软件拦截。"
    exit 1
}

while ($true) {
    try {
        $context  = $listener.GetContext()
    } catch {
        Write-StartupError "write_server 监听中断: $($_.Exception.Message)"
        break
    }
    $request  = $context.Request
    $response = $context.Response

    $response.AddHeader("Access-Control-Allow-Origin",  "*")
    $response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
    $response.AddHeader("Access-Control-Allow-Headers", "Content-Type")

    if ($request.HttpMethod -eq "OPTIONS") {
        $response.StatusCode      = 204
        $response.ContentLength64 = 0
        $response.Close()
        continue
    }

    $stream = $request.InputStream
    $reader = New-Object System.IO.MemoryStream
    $buffer = New-Object byte[] 4096
    while ($true) {
        $bytesRead = $stream.Read($buffer, 0, $buffer.Length)
        if ($bytesRead -eq 0) { break }
        $reader.Write($buffer, 0, $bytesRead)
    }
    $body = [System.Text.Encoding]::UTF8.GetString($reader.ToArray())

    $queryParams  = $request.Url.Query.TrimStart("?").Split("&")
    $actionParam  = ($queryParams | Where-Object { $_ -like "action=*" }) -replace "^action=", ""

    if ($actionParam) {
        $payload = $null
        if ($body) {
            try { $payload = $body | ConvertFrom-Json } catch { $payload = $null }
        }
        if ($null -eq $payload) { $payload = [PSCustomObject]@{} }
        $json = Invoke-Action -Action $actionParam -Payload $payload
        $outBytes = [System.Text.Encoding]::UTF8.GetBytes($json)
        $response.ContentType     = "application/json; charset=utf-8"
        $response.ContentLength64 = $outBytes.Length
        $response.OutputStream.Write($outBytes, 0, $outBytes.Length)
        $response.OutputStream.Close()
        continue
    }

    # 原有行为: 整份覆写文件(savefile()/openfile() 用这个), 不带 action 参数时保持不变
    $filePathParam = ($queryParams | Where-Object { $_ -like "file=*" }) -replace "^file=", ""
    if ($filePathParam) {
        $decodedPath = [System.Uri]::UnescapeDataString($filePathParam)
        [System.IO.File]::WriteAllText($decodedPath, $body)
    }

    $outBytes = [System.Text.Encoding]::UTF8.GetBytes("true")
    $response.ContentType      = "text/plain; charset=utf-8"
    $response.ContentLength64  = $outBytes.Length
    $response.OutputStream.Write($outBytes, 0, $outBytes.Length)
    $response.OutputStream.Close()
}
