Set-StrictMode -Version Latest

# ВАЖНО: ни одна функция этого модуля не пишет в success stream — stdout занят JSON-RPC.

function Read-KompasMcpLock {
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string] $Path)

    if (-not (Test-Path -LiteralPath $Path)) { throw "нет файла пина версии: $Path" }

    $raw = Get-Content -LiteralPath $Path -Raw
    try {
        $lock = $raw | ConvertFrom-Json
    }
    catch {
        throw "не удалось разобрать server.lock.json ($Path): $($_.Exception.Message)"
    }

    # JSON-содержимое "null" (и пустой файл) разбирается ConvertFrom-Json в $null
    # без исключения — но $null.PSObject бросает PropertyNotFoundException под
    # Set-StrictMode, поэтому его нужно исключить раньше, чем что-либо трогать.
    if ($null -eq $lock) {
        throw "server.lock.json ($Path) повреждён: файл разбирается в null (пустой/повреждённый лок)"
    }

    # Под Set-StrictMode -Version Latest член-перечисление .Properties.Name
    # бросает PropertyNotFoundException, если коллекция .Properties ПУСТА (это
    # ровно случай лока вида "{}" — валидный JSON без единого ключа). Индексатор
    # PSObject.Properties['имя'] свободен от этого: он всегда возвращает либо
    # PSPropertyInfo, либо $null, независимо от того, пуста коллекция или нет —
    # поэтому наличие каждого поля проверяем именно так, а не через -contains.
    $versionProp = $lock.PSObject.Properties['version']
    if ($null -eq $versionProp -or [string]::IsNullOrWhiteSpace([string]$versionProp.Value)) {
        throw "server.lock.json ($Path) повреждён: нет поля version"
    }
    $version = [string]$versionProp.Value

    if ($version -eq '0.0.0') {
        throw "релиз сервера ещё не опубликован (server.lock.json пуст). Соберите сервер локально и укажите путь в KOMPAS_MCP_EXE."
    }

    $urlProp = $lock.PSObject.Properties['url']
    if ($null -eq $urlProp -or [string]::IsNullOrWhiteSpace([string]$urlProp.Value)) {
        throw "server.lock.json ($Path) указывает опубликованную версию $version, но не содержит url для загрузки"
    }

    $sha256Prop = $lock.PSObject.Properties['sha256']
    if ($null -eq $sha256Prop -or [string]::IsNullOrWhiteSpace([string]$sha256Prop.Value) -or ([string]$sha256Prop.Value -notmatch '^[0-9a-fA-F]{64}$')) {
        throw "server.lock.json ($Path) указывает опубликованную версию $version, но sha256 отсутствует или некорректен (ожидались 64 hex-символа)"
    }

    return $lock
}

function Test-KompasMcpInstall {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Directory,
        [Parameter(Mandatory)] $Lock
    )

    $marker = Join-Path $Directory 'install.json'
    $exe = Join-Path $Directory 'kompas-mcp.exe'
    if (-not (Test-Path -LiteralPath $marker)) { return $false }
    if (-not (Test-Path -LiteralPath $exe)) { return $false }

    try {
        $installed = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json
    }
    catch {
        # Намеренно ловим ЛЮБУЮ ошибку чтения/разбора маркера — не только битый
        # JSON, но и, например, файл, временно заблокированный антивирусом.
        # В любом из этих случаев правильный ответ один: считать установку
        # негодной и позволить Install-KompasMcpServer перекачать заново,
        # а не падать здесь с сырым исключением.
        return $false
    }

    return ($installed.version -eq $Lock.version) -and ($installed.sha256 -eq $Lock.sha256)
}

function Remove-KompasMcpStaleTemp {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $TempRoot,
        [timespan] $MaxAge = ([timespan]::FromDays(1))
    )

    # Уборка мусора не имеет права мешать запуску сервера: любая ошибка (каталог
    # держит соседний процесс, отказано в доступе, гонка на файловой системе)
    # гасится здесь. Диагностика уходит в verbose stream — stdout занят JSON-RPC.
    try {
        if (-not (Test-Path -LiteralPath $TempRoot)) { return }

        $deadline = (Get-Date).ToUniversalTime() - $MaxAge

        foreach ($dir in (Get-ChildItem -LiteralPath $TempRoot -Directory -Force -ErrorAction SilentlyContinue)) {
            try {
                # Время записи самого каталога не обновляется, пока сосед льёт в
                # него один и тот же server.zip (меняется файл, а не список
                # содержимого), поэтому за «живость» отвечает максимум по всему
                # поддереву — иначе можно снести чужую активную установку.
                $touched = $dir.LastWriteTimeUtc
                foreach ($item in (Get-ChildItem -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue)) {
                    if ($item.LastWriteTimeUtc -gt $touched) { $touched = $item.LastWriteTimeUtc }
                }

                if ($touched -lt $deadline) {
                    Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
                }
            }
            catch {
                Write-Verbose "не удалось убрать временный каталог $($dir.FullName): $($_.Exception.Message)"
            }
        }
    }
    catch {
        Write-Verbose "не удалось обойти временный каталог ${TempRoot}: $($_.Exception.Message)"
    }
}

function Install-KompasMcpServer {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Lock,
        [Parameter(Mandatory)] [string] $InstallRoot,
        [scriptblock] $Downloader
    )

    # Подметаем брошенные .tmp\<guid> ДО проверки уже установленной версии: finally
    # ниже спасает только от штатного throw/return, а после принудительного убийства
    # процесса (kill, перезагрузка) посреди установки каталог остаётся навсегда —
    # по ~70 МБ на оборванный запуск, сервер публикуется self-contained. Место в
    # горячем пути дешёвое: без каталога .tmp всё сводится к одному Test-Path.
    Remove-KompasMcpStaleTemp -TempRoot (Join-Path $InstallRoot '.tmp')

    $target = Join-Path $InstallRoot $Lock.version
    if (Test-KompasMcpInstall -Directory $target -Lock $Lock) { return $target }

    if ($null -eq $Downloader) {
        $Downloader = {
            param($Url, $Destination)
            Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing
        }
    }

    # Временный каталог — под InstallRoot: перенос каталога атомарен только в пределах тома.
    $tempRoot = Join-Path $InstallRoot '.tmp'
    New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
    $work = Join-Path $tempRoot ([guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $work -Force | Out-Null

    try {
        $zip = Join-Path $work 'server.zip'
        # Downloader внедряемый (в проде — Invoke-WebRequest, в тестах — фейк);
        # | Out-Null на случай, если чужая реализация случайно вернёт объект
        # в success stream — stdout занят JSON-RPC, лишний вывод недопустим.
        & $Downloader $Lock.url $zip | Out-Null
        if (-not (Test-Path -LiteralPath $zip)) { throw "загрузка не создала файл: $($Lock.url)" }

        $actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
        $expected = ([string]$Lock.sha256).ToLowerInvariant()
        if ($actual -ne $expected) {
            throw "SHA256 архива не совпал: ожидался $expected, получен $actual"
        }

        $payload = Join-Path $work 'payload'
        Expand-Archive -LiteralPath $zip -DestinationPath $payload -Force
        if (-not (Test-Path -LiteralPath (Join-Path $payload 'kompas-mcp.exe'))) {
            throw "в архиве нет kompas-mcp.exe в корне"
        }

        $marker = [ordered]@{
            version     = $Lock.version
            sha256      = $expected
            sourceSha   = $Lock.sourceSha
            installedAt = (Get-Date).ToString('o')
        }
        # -Encoding utf8 достаточно: значения маркера чисто ASCII (версия/хеш/ISO-дата),
        # BOM (если добавится под 5.1) не мешает Get-Content -Raw | ConvertFrom-Json.
        Set-Content -LiteralPath (Join-Path $payload 'install.json') `
                    -Value ($marker | ConvertTo-Json) -Encoding utf8

        New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null

        if (Test-Path -LiteralPath $target) {
            # Гонку с соседним процессом выиграл он — чужая установка уже на месте.
            if (Test-KompasMcpInstall -Directory $target -Lock $Lock) { return $target }
            throw "каталог версии уже существует, но установка в нём негодная: $target"
        }

        try {
            [System.IO.Directory]::Move($payload, $target)
        }
        catch [System.IO.IOException] {
            # Гонку выиграл сосед — это успех, если его установка годная.
            if (-not (Test-KompasMcpInstall -Directory $target -Lock $Lock)) { throw }
        }
        return $target
    }
    finally {
        Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Resolve-KompasMcpExecutable {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Lock,
        [string] $InstallRoot,
        # Пробрасывается в Install-KompasMcpServer — прод по умолчанию использует
        # Invoke-WebRequest; параметр существует в первую очередь для тестов,
        # чтобы прогонять конкурентную/mutex-логику без реальной сети.
        [scriptblock] $Downloader
    )

    if ([string]::IsNullOrWhiteSpace($InstallRoot)) {
        $InstallRoot = Join-Path $env:LOCALAPPDATA 'kompas-mcp'
    }

    # Именованный mutex без префикса "Global\": сессионного мьютекса достаточно,
    # чтобы синхронизировать процессы одной сессии Windows. "Global\" не берём
    # осознанно: плагин ставится на чужие незнакомые машины, где политика
    # безопасности может запретить создание глобальных объектов — тогда отказ
    # WaitOne/New-Object был бы фатальным и сломал бы запуск сервера. Цена отказа
    # от "Global\" невелика: при одновременном старте из разных сессий (RDP +
    # консоль) возможна лишняя повторная загрузка, но не порча установки — от неё
    # защищают проверка целевого каталога, сверка SHA256 и обработка IOException
    # при переносе каталога.
    $mutex = New-Object System.Threading.Mutex($false, "kompas-mcp-install-$($Lock.version)")
    $acquired = $false
    try {
        try {
            $acquired = $mutex.WaitOne()
        }
        catch [System.Threading.AbandonedMutexException] {
            # Предыдущий владелец умер, не освободив mutex посреди установки
            # (оборванная закачка, таймаут MCP-хоста, антивирус остановил процесс) —
            # ровно тот сценарий, ради которого mutex и заведён. .NET в этом случае
            # всё равно передаёт владение текущему потоку, поэтому продолжаем
            # работу как обычно, а не считаем это ошибкой.
            $acquired = $true
        }

        $dir = Install-KompasMcpServer -Lock $Lock -InstallRoot $InstallRoot -Downloader $Downloader
        return (Join-Path $dir 'kompas-mcp.exe')
    }
    finally {
        # ReleaseMutex зовём только если реально владеем mutex'ом — иначе он
        # бросает SynchronizationLockException и маскирует настоящую ошибку.
        if ($acquired) { $mutex.ReleaseMutex() }
        $mutex.Dispose()
    }
}

Export-ModuleMember -Function Read-KompasMcpLock, Test-KompasMcpInstall, Remove-KompasMcpStaleTemp, Install-KompasMcpServer, Resolve-KompasMcpExecutable
