BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' '..' 'plugin' 'scripts' 'KompasMcpBootstrap.psm1') -Force

    function New-TempRoot {
        $root = Join-Path ([System.IO.Path]::GetTempPath()) ("kompasmcp_" + [guid]::NewGuid().ToString('N'))
        New-Item -ItemType Directory -Path $root -Force | Out-Null
        return $root
    }

    # Готовит zip с «сервером» и возвращает лок с настоящим SHA256.
    function New-FakeRelease([string] $Root, [string] $Version) {
        $payload = Join-Path $Root 'payload'
        New-Item -ItemType Directory -Path $payload -Force | Out-Null
        Set-Content -LiteralPath (Join-Path $payload 'kompas-mcp.exe') -Value 'not-a-real-exe' -Encoding ascii

        $zip = Join-Path $Root 'release.zip'
        Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $zip -Force
        Remove-Item -LiteralPath $payload -Recurse -Force

        $sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
        return [pscustomobject]@{
            Zip  = $zip
            Lock = [pscustomobject]@{ version = $Version; sourceSha = 'deadbeef'; url = "file:///$zip"; sha256 = $sha }
        }
    }

    # Имитирует брошенный временный каталог установки: с файлом внутри и заданным
    # «возрастом» (сдвигаем время записи и каталога, и содержимого).
    function New-TempWorkDir([string] $TempRoot, [string] $Name, [timespan] $Age) {
        $dir = Join-Path $TempRoot $Name
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        Set-Content -LiteralPath (Join-Path $dir 'server.zip') -Value 'недокачанный архив' -Encoding ascii

        $stamp = (Get-Date).ToUniversalTime() - $Age
        Get-ChildItem -LiteralPath $dir -Recurse -Force | ForEach-Object { $_.LastWriteTimeUtc = $stamp }
        (Get-Item -LiteralPath $dir).LastWriteTimeUtc = $stamp
        return $dir
    }
}

Describe 'Install-KompasMcpServer' {
    It 'ставит сервер, пишет маркер и возвращает путь к каталогу версии' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'

            $dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
                param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
            }

            $dir | Should -Be (Join-Path $installRoot '1.0.0')
            Test-Path -LiteralPath (Join-Path $dir 'kompas-mcp.exe') | Should -BeTrue
            (Get-Content -LiteralPath (Join-Path $dir 'install.json') -Raw | ConvertFrom-Json).sha256 |
                Should -Be $rel.Lock.sha256
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'отказывается ставить при несовпадении SHA256 и не оставляет каталог версии' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $rel.Lock.sha256 = '0' * 64
            $installRoot = Join-Path $root 'install'

            { Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
                param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
            } } | Should -Throw '*SHA256*'

            Test-Path -LiteralPath (Join-Path $installRoot '1.0.0') | Should -BeFalse
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'не скачивает повторно, если установка уже годная' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'
            $script:calls = 0
            $downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }

            Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null
            Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null

            $script:calls | Should -Be 1
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'считает установку негодной, если маркер не совпадает с локом' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'
            $dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
                param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
            }

            Set-Content -LiteralPath (Join-Path $dir 'install.json') -Value '{"version":"1.0.0","sha256":"нечто"}' -Encoding utf8
            Test-KompasMcpInstall -Directory $dir -Lock $rel.Lock | Should -BeFalse
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'гонка: если целевой каталог версии уже содержит годную установку, не скачивает и возвращает его путь' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'
            $target = Join-Path $installRoot '1.0.0'

            # Имитируем "соседа", который уже успешно установил версию раньше нас.
            New-Item -ItemType Directory -Path $target -Force | Out-Null
            Set-Content -LiteralPath (Join-Path $target 'kompas-mcp.exe') -Value 'not-a-real-exe' -Encoding ascii
            $neighborMarker = [ordered]@{ version = $rel.Lock.version; sha256 = $rel.Lock.sha256 }
            Set-Content -LiteralPath (Join-Path $target 'install.json') -Value ($neighborMarker | ConvertTo-Json) -Encoding utf8

            $script:calls = 0
            $downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }

            $dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader

            $dir | Should -Be $target
            $script:calls | Should -Be 0
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'гонка: если целевой каталог версии существует, но установка в нём негодная, бросает внятную ошибку' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'
            $target = Join-Path $installRoot '1.0.0'

            # Каталог версии существует (например, остался от оборванной установки),
            # но без валидного маркера/exe — установка в нём негодная.
            New-Item -ItemType Directory -Path $target -Force | Out-Null

            { Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
                param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
            } } | Should -Throw '*негодная*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    # Без угловых скобок в имени: Pester трактует <...> как подстановку переменной.
    It 'убирает брошенный временный каталог от оборванной прошлой установки' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'
            $tempRoot = Join-Path $installRoot '.tmp'
            New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
            $abandoned = New-TempWorkDir -TempRoot $tempRoot -Name 'abandoned' -Age ([timespan]::FromDays(3))

            $dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
                param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
            }

            Test-Path -LiteralPath $abandoned | Should -BeFalse
            Test-Path -LiteralPath (Join-Path $dir 'kompas-mcp.exe') | Should -BeTrue
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'убирает мусор и тогда, когда нужная версия уже установлена (качать нечего)' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'
            $downloader = { param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }

            Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null

            $abandoned = New-TempWorkDir -TempRoot (Join-Path $installRoot '.tmp') -Name 'abandoned' -Age ([timespan]::FromDays(3))
            Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null

            Test-Path -LiteralPath $abandoned | Should -BeFalse
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }
}

Describe 'Remove-KompasMcpStaleTemp' {
    It 'удаляет каталог старше порога и не трогает свежий' {
        $root = New-TempRoot
        try {
            $tempRoot = Join-Path $root '.tmp'
            New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
            $stale = New-TempWorkDir -TempRoot $tempRoot -Name 'stale' -Age ([timespan]::FromDays(2))
            $fresh = New-TempWorkDir -TempRoot $tempRoot -Name 'fresh' -Age ([timespan]::FromMinutes(5))

            Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1))

            Test-Path -LiteralPath $stale | Should -BeFalse
            Test-Path -LiteralPath $fresh | Should -BeTrue
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'не трогает чужую активную установку: каталог старый, но внутрь только что писали' {
        $root = New-TempRoot
        try {
            $tempRoot = Join-Path $root '.tmp'
            New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
            # Сосед начал качать давно и всё ещё льёт байты в тот же server.zip:
            # время записи самого каталога с тех пор не менялось, время файла — да.
            $active = New-TempWorkDir -TempRoot $tempRoot -Name 'active' -Age ([timespan]::FromDays(2))
            (Get-Item -LiteralPath (Join-Path $active 'server.zip')).LastWriteTimeUtc = (Get-Date).ToUniversalTime()

            Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1))

            Test-Path -LiteralPath $active | Should -BeTrue
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'молча переживает отсутствующий .tmp' {
        $root = New-TempRoot
        try {
            { Remove-KompasMcpStaleTemp -TempRoot (Join-Path $root '.tmp') } | Should -Not -Throw
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'не падает на неудаляемом каталоге и всё равно убирает остальные' {
        $root = New-TempRoot
        $stream = $null
        try {
            $tempRoot = Join-Path $root '.tmp'
            New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
            $locked = New-TempWorkDir -TempRoot $tempRoot -Name 'locked' -Age ([timespan]::FromDays(2))
            $stale = New-TempWorkDir -TempRoot $tempRoot -Name 'stale' -Age ([timespan]::FromDays(2))

            # Открытый дескриптор не даёт Windows удалить файл — Remove-Item обязан
            # отработать вхолостую, а не сорвать уборку остального мусора.
            $stream = [System.IO.File]::Open((Join-Path $locked 'server.zip'), 'Open', 'Read', 'None')

            { Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1)) } | Should -Not -Throw

            Test-Path -LiteralPath (Join-Path $locked 'server.zip') | Should -BeTrue
            Test-Path -LiteralPath $stale | Should -BeFalse
        } finally {
            if ($null -ne $stream) { $stream.Dispose() }
            Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    It 'ничего не пишет в success stream' {
        $root = New-TempRoot
        try {
            $tempRoot = Join-Path $root '.tmp'
            New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
            New-TempWorkDir -TempRoot $tempRoot -Name 'stale' -Age ([timespan]::FromDays(2)) | Out-Null

            $out = Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1))

            $out | Should -BeNullOrEmpty
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }
}

Describe 'Resolve-KompasMcpExecutable' {
    It 'возвращает путь к exe и не скачивает повторно при втором вызове' {
        $root = New-TempRoot
        try {
            $rel = New-FakeRelease -Root $root -Version '1.0.0'
            $installRoot = Join-Path $root 'install'
            $script:calls = 0
            $downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }

            $exe1 = Resolve-KompasMcpExecutable -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader
            $exe2 = Resolve-KompasMcpExecutable -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader

            $exe1 | Should -Be (Join-Path $installRoot '1.0.0' 'kompas-mcp.exe')
            $exe2 | Should -Be $exe1
            Test-Path -LiteralPath $exe1 | Should -BeTrue
            $script:calls | Should -Be 1
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }
}

Describe 'Read-KompasMcpLock' {
    It 'падает внятно, когда релиза ещё нет' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            Set-Content -LiteralPath $path -Value '{"version":"0.0.0","sourceSha":"","url":"","sha256":""}' -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*релиз*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'счастливый путь: валидный опубликованный лок разбирается и возвращает поля' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            $sha = '1' * 64
            Set-Content -LiteralPath $path -Value "{`"version`":`"1.2.3`",`"sourceSha`":`"deadbeef`",`"url`":`"https://example.invalid/release.zip`",`"sha256`":`"$sha`"}" -Encoding utf8

            $lock = Read-KompasMcpLock -Path $path

            $lock.version | Should -Be '1.2.3'
            $lock.url | Should -Be 'https://example.invalid/release.zip'
            $lock.sha256 | Should -Be $sha
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'падает внятно на битом JSON' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            Set-Content -LiteralPath $path -Value '{ не json вообще' -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*server.lock.json*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'падает внятно, если в валидном JSON нет поля version' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            Set-Content -LiteralPath $path -Value '{"sourceSha":"deadbeef","url":"https://example.invalid/release.zip","sha256":"1111111111111111111111111111111111111111111111111111111111111111"}' -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*version*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'падает внятно, если у опубликованной версии нет поля url' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            $sha = '1' * 64
            Set-Content -LiteralPath $path -Value "{`"version`":`"1.2.3`",`"sourceSha`":`"deadbeef`",`"sha256`":`"$sha`"}" -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*url*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'падает внятно, если у опубликованной версии sha256 пустой или некорректной длины' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            Set-Content -LiteralPath $path -Value '{"version":"1.2.3","sourceSha":"deadbeef","url":"https://example.invalid/release.zip","sha256":"beef"}' -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*sha256*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'падает внятно на локе вида "{}" (валидный JSON, ноль ключей)' {
        # Регрессия: под Set-StrictMode -Version Latest член-перечисление
        # .PSObject.Properties.Name бросает PropertyNotFoundException, когда
        # коллекция .Properties ПУСТА — ровно случай "{}". Это не гипотетика:
        # placeholder-лок до заполнения релизным пайплайном литерально "{}".
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            Set-Content -LiteralPath $path -Value '{}' -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*version*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'падает внятно, если лок — это JSON null' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            Set-Content -LiteralPath $path -Value 'null' -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*server.lock.json*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'падает внятно, если лок — это пустая JSON-строка вместо объекта' {
        $root = New-TempRoot
        try {
            $path = Join-Path $root 'server.lock.json'
            Set-Content -LiteralPath $path -Value '""' -Encoding utf8

            { Read-KompasMcpLock -Path $path } | Should -Throw '*version*'
        } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
    }
}

# Известные пробелы покрытия (осознанно НЕ автотестируются здесь):
# - ветка `catch [System.IO.IOException]` вокруг [System.IO.Directory]::Move
#   в Install-KompasMcpServer (настоящий TOCTOU между двумя процессами —
#   детерминированно в unit-тесте не воспроизводится без реальной гонки потоков
#   на файловой системе);
# - ветка `catch [System.Threading.AbandonedMutexException]` в
#   Resolve-KompasMcpExecutable — проверена вручную (owner-процесс убит,
#   пока waiter уже заблокирован в WaitOne(), waiter получает
#   AbandonedMutexException и корректно продолжает работу), но не автотестом:
#   надёжное воспроизведение требует реального межпроцессного таймингового
#   сценария (Start-Process + Stop-Process -Force), что делает тест хрупким
#   и медленным для обычного прогона.
