Set-StrictMode -Version Latest

# Теневой запуск dev-сборки MCP-сервера.
#
# Задача: разорвать связь между каталогом сборки и каталогом запуска. Пока сервер
# запущен прямо из src\...\bin\x64\Release\net8.0-windows, он держит открытым образ
# kompas-mcp.exe и Kompas.Mcp.Core.dll, и `dotnet build` падает с MSB3027/MSB3021
# («файл используется другим процессом»), причём сразу от всех живых сессий MCP.
# Лаунчер копирует сборку в приватный каталог и запускает сервер оттуда — bin остаётся
# свободным, собирать можно не закрывая сессию.
#
# ВАЖНО: ни одна функция этого модуля не пишет в success stream — stdout занят JSON-RPC.

$script:MarkerName = '.kompas-mcp-dev.json'

function Get-KompasMcpBuildFingerprint {
    <#
    .SYNOPSIS
    Отпечаток каталога сборки: меняется при любой пересборке.
    #>
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string] $Directory)

    if (-not (Test-Path -LiteralPath $Directory -PathType Container)) {
        throw "нет каталога сборки: $Directory"
    }

    $root = (Resolve-Path -LiteralPath $Directory).ProviderPath.TrimEnd('\')
    # @(...) обязательно: под Windows PowerShell 5.1 (в котором .mcp.json запускает
    # лаунчер) Get-ChildItem с единственным файлом возвращает скаляр, и обращение
    # к .Count под Set-StrictMode падает PropertyNotFoundException. В pwsh 7 то же
    # выражение работает — то есть без обёртки баг не воспроизводится при локальной
    # проверке и проявляется только у запущенного сервера.
    $files = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction Stop)

    if ($files.Count -eq 0) { throw "каталог сборки пуст: $Directory" }

    # В отпечаток идут относительный путь, размер и время записи каждого файла:
    # дёшево (без чтения содержимого) и ловит любую пересборку, включая ту, что
    # вернула байт-в-байт тот же код при изменившемся составе файлов.
    $lines = New-Object System.Collections.Generic.List[string]
    foreach ($f in $files) {
        $rel = $f.FullName.Substring($root.Length).TrimStart('\')
        $lines.Add(('{0}|{1}|{2}' -f $rel.ToLowerInvariant(), $f.Length, $f.LastWriteTimeUtc.Ticks))
    }

    # Сортировка строго ordinal, а НЕ через Sort-Object: тот сортирует по правилам
    # культуры, а Windows PowerShell 5.1 (NLS) и pwsh 7 (ICU) упорядочивают строки
    # с пунктуацией по-разному — «kompas-mcp.exe» и «kompas.mcp.core.dll» меняются
    # местами. Отпечаток тогда зависел бы от того, кто его считает: лаунчер (5.1)
    # или rebuild-mcp.ps1 (7), и каталог теневой копии не совпал бы с отчётом сборки.
    $lines.Sort([System.StringComparer]::Ordinal)
    $payload = $lines -join "`n"
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($payload))
    }
    finally { $sha.Dispose() }

    # 16 hex-символов (64 бита) — коллизия неправдоподобна, а имя каталога читаемое.
    return (-join ($hash[0..7] | ForEach-Object { $_.ToString('x2') }))
}

function Test-KompasMcpShadow {
    <#
    .SYNOPSIS
    Годна ли теневая копия: есть exe и маркер с тем же отпечатком.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Directory,
        [Parameter(Mandatory)] [string] $Fingerprint
    )

    $exe = Join-Path $Directory 'kompas-mcp.exe'
    $marker = Join-Path $Directory $script:MarkerName
    if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { return $false }
    if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { return $false }

    try {
        $info = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json
    }
    catch {
        # Любая ошибка чтения/разбора маркера (битый JSON, файл занят антивирусом,
        # оборванная запись) означает одно: копию считаем негодной и делаем заново.
        return $false
    }

    if ($null -eq $info) { return $false }
    $prop = $info.PSObject.Properties['fingerprint']
    if ($null -eq $prop) { return $false }
    return ([string]$prop.Value -eq $Fingerprint)
}

function Get-KompasMcpRunningShadow {
    <#
    .SYNOPSIS
    Каталоги, из которых прямо сейчас запущены процессы kompas-mcp (в нижнем регистре).
    #>
    [CmdletBinding()]
    param()

    $dirs = New-Object System.Collections.Generic.HashSet[string]
    foreach ($p in (Get-Process -Name 'kompas-mcp' -ErrorAction SilentlyContinue)) {
        try {
            # .Path бросает Win32Exception для процесса чужого пользователя или
            # уже умершего между перечислением и обращением — такой процесс просто
            # не учитываем: хуже от этого только лишняя попытка уборки, которая
            # сама по себе безопасна (все ошибки удаления гасятся).
            if ($p.Path) { [void]$dirs.Add((Split-Path -Parent $p.Path).ToLowerInvariant()) }
        }
        catch { }
    }
    return $dirs
}

function Remove-KompasMcpStaleShadow {
    <#
    .SYNOPSIS
    Убрать старые теневые копии (каждая ~10 МБ), не трогая используемые.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Root,
        [string] $Keep,
        [timespan] $MaxAge = ([timespan]::FromDays(3))
    )

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

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

        foreach ($dir in (Get-ChildItem -LiteralPath $Root -Directory -Force -ErrorAction SilentlyContinue)) {
            try {
                if ($Keep -and $dir.Name -eq $Keep) { continue }
                if ($dir.Name -eq '.tmp') { continue }

                # Каталог, из которого работает живой сервер, не трогаем даже если он
                # старый: Remove-Item -Recurse снёс бы вспомогательные файлы вокруг
                # залоченного exe и покалечил бы чужую сессию (сборки грузятся лениво,
                # часть из них могла быть ещё не загружена).
                if ($busy.Contains($dir.FullName.ToLowerInvariant())) { continue }

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

function Copy-KompasMcpBuild {
    <#
    .SYNOPSIS
    Разложить сборку в <InstallRoot>\<отпечаток>, если её там ещё нет. Возвращает каталог.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $SourceDirectory,
        [Parameter(Mandatory)] [string] $InstallRoot,
        [Parameter(Mandatory)] [string] $Fingerprint
    )

    $target = Join-Path $InstallRoot $Fingerprint
    if (Test-KompasMcpShadow -Directory $target -Fingerprint $Fingerprint) { return $target }

    if (-not (Test-Path -LiteralPath (Join-Path $SourceDirectory 'kompas-mcp.exe') -PathType Leaf)) {
        throw "в каталоге сборки нет kompas-mcp.exe: $SourceDirectory. Соберите сервер: dotnet build -c Release"
    }

    # Временный каталог — под 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 {
        Copy-Item -LiteralPath $SourceDirectory -Destination $work -Recurse -Force -Container
        # Copy-Item -Container кладёт исходный каталог ВНУТРЬ приёмника, а не разливает
        # его содержимое: полезная нагрузка оказывается в подкаталоге с исходным именем.
        $payload = Join-Path $work (Split-Path -Leaf $SourceDirectory)
        if (-not (Test-Path -LiteralPath (Join-Path $payload 'kompas-mcp.exe') -PathType Leaf)) {
            throw "копирование не воспроизвело kompas-mcp.exe в $payload"
        }

        $marker = [ordered]@{
            fingerprint = $Fingerprint
            source      = $SourceDirectory
            copiedAt    = (Get-Date).ToString('o')
        }
        Set-Content -LiteralPath (Join-Path $payload $script:MarkerName) `
                    -Value ($marker | ConvertTo-Json) -Encoding utf8

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

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

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

function Resolve-KompasMcpDevExecutable {
    <#
    .SYNOPSIS
    Путь к exe теневой копии текущей сборки (копируя её при необходимости).
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $SourceDirectory,
        [string] $InstallRoot
    )

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

    $fp = Get-KompasMcpBuildFingerprint -Directory $SourceDirectory

    # Сессионный mutex (без "Global\") — как в KompasMcpBootstrap.psm1: синхронизирует
    # одновременный старт нескольких сессий MCP на одной и той же свежей сборке.
    $mutex = New-Object System.Threading.Mutex($false, "kompas-mcp-dev-$fp")
    $acquired = $false
    try {
        try {
            $acquired = $mutex.WaitOne()
        }
        catch [System.Threading.AbandonedMutexException] {
            # Предыдущий владелец умер посреди копирования — владение всё равно
            # переходит к нам, продолжаем как обычно.
            $acquired = $true
        }

        $dir = Copy-KompasMcpBuild -SourceDirectory $SourceDirectory -InstallRoot $InstallRoot -Fingerprint $fp
        Remove-KompasMcpStaleShadow -Root $InstallRoot -Keep $fp
        return (Join-Path $dir 'kompas-mcp.exe')
    }
    finally {
        if ($acquired) { $mutex.ReleaseMutex() }
        $mutex.Dispose()
    }
}

Export-ModuleMember -Function Get-KompasMcpBuildFingerprint, Test-KompasMcpShadow, `
    Get-KompasMcpRunningShadow, Remove-KompasMcpStaleShadow, Copy-KompasMcpBuild, `
    Resolve-KompasMcpDevExecutable
