#requires -version 5.1
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

$BaseDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$HtmlPath = Join-Path $BaseDir 'BDO_연금_요리_가공_재료계산기.html'
$Port = 8765
$script:LastItemsPath = $null
$script:LastRecipesPath = $null
$script:LastKoNamesPath = $null
$BundledItemsPath = Join-Path $BaseDir 'items.json'
$BundledRecipesPath = Join-Path $BaseDir 'recipes.json'
$BundledKoPath = Join-Path $BaseDir 'item_names_ko.json'
$script:LastHeartbeat = [DateTime]::UtcNow
$script:HeartbeatSeen = $false
$script:StopRequested = $false
$script:PageCloseCandidate = $null
$script:HelperStartTime = [DateTime]::UtcNow
$script:PendingGameDir = $null

function New-TopMostOwner {
    $owner = New-Object System.Windows.Forms.Form
    $owner.TopMost = $true
    $owner.ShowInTaskbar = $false
    $owner.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
    $owner.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedToolWindow
    $owner.Size = New-Object System.Drawing.Size(1,1)
    $owner.Opacity = 0
    $owner.Show()
    $owner.BringToFront()
    $owner.Activate()
    return $owner
}
function Show-Info([string]$Message, [string]$Title='BDO 재료 계산기') {
    $owner = New-TopMostOwner
    try { [System.Windows.Forms.MessageBox]::Show($owner, $Message, $Title, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information) | Out-Null } finally { $owner.Close(); $owner.Dispose() }
}
function Show-ErrorBox([string]$Message, [string]$Title='BDO 재료 계산기') {
    $owner = New-TopMostOwner
    try { [System.Windows.Forms.MessageBox]::Show($owner, $Message, $Title, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null } finally { $owner.Close(); $owner.Dispose() }
}
function Ask-YesNo([string]$Message, [string]$Title='BDO 재료 계산기') {
    $owner = New-TopMostOwner
    try { return [System.Windows.Forms.MessageBox]::Show($owner, $Message, $Title, [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Question) -eq [System.Windows.Forms.DialogResult]::Yes } finally { $owner.Close(); $owner.Dispose() }
}
function Ask-OKCancel([string]$Message, [string]$Title='BDO 재료 계산기') {
    $owner = New-TopMostOwner
    try { return [System.Windows.Forms.MessageBox]::Show($owner, $Message, $Title, [System.Windows.Forms.MessageBoxButtons]::OKCancel, [System.Windows.Forms.MessageBoxIcon]::Warning) -eq [System.Windows.Forms.DialogResult]::OK } finally { $owner.Close(); $owner.Dispose() }
}
function Pick-Folder([string]$Description, [string]$InitialPath='') {
    $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
    $dlg.Description = $Description
    $dlg.ShowNewFolderButton = $true
    if ($InitialPath -and (Test-Path -LiteralPath $InitialPath)) { $dlg.SelectedPath = $InitialPath }
    $owner = New-TopMostOwner
    try { $result = $dlg.ShowDialog($owner) } finally { $owner.Close(); $owner.Dispose() }
    if ($result -eq [System.Windows.Forms.DialogResult]::OK) { return $dlg.SelectedPath }
    return $null
}
function Pick-File([string]$Title, [string]$Filter='모든 파일 (*.*)|*.*', [string]$InitialDirectory='') {
    $dlg = New-Object System.Windows.Forms.OpenFileDialog
    $dlg.Title = $Title
    $dlg.Filter = $Filter
    $dlg.CheckFileExists = $true
    $dlg.Multiselect = $false
    if ($InitialDirectory -and (Test-Path -LiteralPath $InitialDirectory)) { $dlg.InitialDirectory = $InitialDirectory }
    $owner = New-TopMostOwner
    try { $result = $dlg.ShowDialog($owner) } finally { $owner.Close(); $owner.Dispose() }
    if ($result -eq [System.Windows.Forms.DialogResult]::OK) { return $dlg.FileName }
    return $null
}
function FullPath([string]$Path) { return [System.IO.Path]::GetFullPath($Path).TrimEnd('\') }
function Is-PathInside([string]$Child, [string]$Parent) {
    $c = (FullPath $Child) + '\'
    $p = (FullPath $Parent) + '\'
    return $c.StartsWith($p, [System.StringComparison]::OrdinalIgnoreCase)
}

function Invoke-NativeCommand([string]$FilePath, [string[]]$Arguments) {
    # Windows PowerShell 5.1은 네이티브 프로그램의 stderr를 2>&1로 받을 때
    # 정상 진행 메시지(예: "go: downloading ...")까지 PowerShell 오류로 승격시킬 수 있습니다.
    # 이 함수 안에서는 잠시 Continue로 낮추고 실제 종료 코드만 성공/실패 판단에 사용합니다.
    $oldPreference = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'
    try {
        $output = @(& $FilePath @Arguments 2>&1)
        $exitCode = $LASTEXITCODE
    } finally {
        $ErrorActionPreference = $oldPreference
    }
    $text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
    return [pscustomobject]@{
        ExitCode = $exitCode
        Output   = $text
    }
}

function Find-Go {
    $cmd = Get-Command go.exe -ErrorAction SilentlyContinue
    if ($cmd) { return $cmd.Source }
    $known = 'C:\Program Files\Go\bin\go.exe'
    if (Test-Path -LiteralPath $known) { return $known }
    return $null
}
function Find-Extractor {
    $localCandidates = @(
        (Join-Path $BaseDir 'bdo-data-extractor.exe'),
        (Join-Path $BaseDir 'tools\bdo-data-extractor.exe'),
        (Join-Path $env:USERPROFILE 'go\bin\bdo-data-extractor.exe')
    )
    foreach ($c in $localCandidates) { if ($c -and (Test-Path -LiteralPath $c)) { return $c } }
    $cmd = Get-Command bdo-data-extractor.exe -ErrorAction SilentlyContinue
    if ($cmd) { return $cmd.Source }
    $cmd2 = Get-Command bdo-data-extractor -ErrorAction SilentlyContinue
    if ($cmd2) { return $cmd2.Source }
    return $null
}
function Ensure-Extractor {
    $existing = Find-Extractor
    if ($existing) { return $existing }

    $go = Find-Go
    if (-not $go) {
        $winget = Get-Command winget.exe -ErrorAction SilentlyContinue
        if (-not $winget) {
            throw "bdo-data-extractor와 Go를 찾지 못했습니다. Go 1.26 이상을 먼저 설치한 뒤 다시 시도하거나, bdo-data-extractor.exe를 이 계산기 폴더에 넣어 주세요."
        }
        $ok = Ask-YesNo "JSON 자동 생성을 위해 bdo-data-extractor가 필요합니다.`r`n`r`n현재 Go가 설치되어 있지 않아 Windows 패키지 관리자(winget)로 최신 Go를 설치한 뒤 추출기를 자동으로 준비할 수 있습니다.`r`n`r`n계속할까요?"
        if (-not $ok) { throw '필요 구성요소 설치를 취소했습니다.' }
        $p = Start-Process -FilePath $winget.Source -ArgumentList @('install','--id','GoLang.Go','-e','--accept-package-agreements','--accept-source-agreements') -Wait -PassThru
        if ($p.ExitCode -ne 0) { throw "Go 자동 설치에 실패했습니다. winget 종료 코드: $($p.ExitCode)" }
        $go = Find-Go
        if (-not $go) { throw 'Go 설치는 끝났지만 go.exe를 찾지 못했습니다. Windows를 재로그인한 뒤 다시 시도해 주세요.' }
    }

    Show-Info "bdo-data-extractor를 처음 한 번 준비합니다. 인터넷 연결 상태에 따라 잠시 걸릴 수 있습니다.`r`n`r`n'go: downloading ...' 같은 문구는 정상 다운로드 진행 메시지입니다. 창을 닫지 말고 완료될 때까지 기다려 주세요.`r`n`r`n준비가 끝나면 자동으로 JSON 생성을 계속합니다."
    $installResult = Invoke-NativeCommand $go @('install','github.com/idevelopthings/bdo-data-extractor@latest')
    if ($installResult.ExitCode -ne 0) {
        $installLog = $installResult.Output
        if (-not $installLog) { $installLog = '(추가 오류 메시지 없음)' }
        throw "bdo-data-extractor 설치에 실패했습니다. (종료 코드 $($installResult.ExitCode))`r`n`r`n$installLog"
    }

    $gobin = (& $go env GOBIN 2>$null | Out-String).Trim()
    if (-not $gobin) {
        $gopath = (& $go env GOPATH 2>$null | Out-String).Trim()
        if ($gopath) { $gobin = Join-Path $gopath 'bin' }
    }
    if ($gobin) {
        $candidate = Join-Path $gobin 'bdo-data-extractor.exe'
        if (Test-Path -LiteralPath $candidate) { return $candidate }
    }
    $again = Find-Extractor
    if ($again) { return $again }
    throw 'bdo-data-extractor 설치가 끝났지만 실행 파일을 찾지 못했습니다.'
}


function Ensure-CompatibilityExtractor {
    # bdo-data-extractor v0.1.7 is intentionally strict and aborts when a live-game
    # patch changes unknown post-icon item metadata. For this recipe calculator we
    # only need the stable/core item fields + recipe relationships. This compatibility
    # build keeps those decoded fields and ignores ONLY that specific strict guard.
    $go = Find-Go
    if (-not $go) { throw '호환 추출기를 만들려면 Go가 필요하지만 go.exe를 찾지 못했습니다.' }

    $compatRoot = Join-Path $env:LOCALAPPDATA 'BDORecipeCalculator\extractor-compat-v0.1.7-r2'
    $compatExe = Join-Path $compatRoot 'bdo-data-extractor-compat-r2.exe'
    if ((Test-Path -LiteralPath $compatExe) -and ((Get-Item -LiteralPath $compatExe).Length -gt 1MB)) {
        return $compatExe
    }

    New-Item -ItemType Directory -Path $compatRoot -Force | Out-Null
    Show-Info "현재 검은사막 클라이언트의 일부 아이템 데이터 구조가 bdo-data-extractor v0.1.7과 달라졌습니다.`r`n`r`n레시피 계산에 필요한 기본 아이템/레시피 데이터는 계속 읽을 수 있으므로, 레시피 계산에 필요하지 않은 'post-icon / footer' 후행 메타데이터 검증만 완화한 호환 추출기를 자동으로 한 번 만듭니다.`r`n`r`n게임 파일은 수정하지 않으며, 잠시 후 자동으로 다시 추출합니다."

    # Ask Go for the exact cached module directory instead of guessing GOPATH paths.
    $downloadResult = Invoke-NativeCommand $go @('mod','download','-json','github.com/idevelopthings/bdo-data-extractor@v0.1.7')
    if ($downloadResult.ExitCode -ne 0) {
        throw "호환 추출기 소스 준비에 실패했습니다.`r`n`r`n$($downloadResult.Output)"
    }
    try {
        $modInfo = $downloadResult.Output | ConvertFrom-Json
    } catch {
        throw "Go 모듈 정보(JSON)를 읽지 못했습니다.`r`n`r`n$($downloadResult.Output)"
    }
    $moduleDir = [string]$modInfo.Dir
    if (-not $moduleDir -or -not (Test-Path -LiteralPath $moduleDir)) {
        throw 'bdo-data-extractor v0.1.7 소스 폴더를 찾지 못했습니다.'
    }

    $srcDir = Join-Path $compatRoot 'src'
    if (Test-Path -LiteralPath $srcDir) { Remove-Item -LiteralPath $srcDir -Recurse -Force -ErrorAction SilentlyContinue }
    New-Item -ItemType Directory -Path $srcDir -Force | Out-Null
    Copy-Item -Path (Join-Path $moduleDir '*') -Destination $srcDir -Recurse -Force
    Get-ChildItem -LiteralPath $srcDir -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object {
        try { if (-not $_.PSIsContainer) { $_.IsReadOnly = $false } } catch {}
    }

    $itemsGo = Join-Path $srcDir 'internal\tables\items.go'
    if (-not (Test-Path -LiteralPath $itemsGo)) { throw "호환 패치 대상 파일을 찾지 못했습니다.`r`n$itemsGo" }
    $code = [System.IO.File]::ReadAllText($itemsGo)
    $pattern = '(?ms)\s*if !p\.OK\(\) \|\| !postReservedOK \{\s*return st, fmt\.Errorf\("itemenchant item %d: post-icon layout changed at %d", id, p\.Pos\(\)-icEnd\)\s*\}'
    $replacement = "`r`n            // BDO Recipe Calculator compatibility mode:`r`n            // ignore only the strict post-icon layout guard; core item fields and recipes remain usable."
    $patchedCode = [regex]::Replace($code, $pattern, $replacement, 1)
    if ($patchedCode -eq $code) {
        throw 'bdo-data-extractor v0.1.7의 예상 코드 위치를 찾지 못해 안전하게 호환 패치를 적용할 수 없습니다.'
    }
    $code = $patchedCode

    # Live KR client can also have a different record footer on the same new item.
    # Footer fields are crystal/transfusion metadata and are not needed by this recipe calculator.
    # If the self-id footer echo is missing, keep the already-decoded core item fields and
    # return success instead of aborting the whole 70k+ item extraction.
    $footerPattern = '(?ms)\s*if l := len\(rec\); l < 12 \|\| bss\.U32\(rec, l-8\) != id \{\s*return st, fmt\.Errorf\("itemenchant item %d: invalid footer", id\)\s*\}'
    $footerReplacement = "`r`n            if l := len(rec); l < 12 || bss.U32(rec, l-8) != id {`r`n                // BDO Recipe Calculator compatibility mode: footer layout changed.`r`n                // JewelGroup/footer metadata is irrelevant to cooking/alchemy/processing recipes.`r`n                return st, nil`r`n            }"
    $footerPatched = [regex]::Replace($code, $footerPattern, $footerReplacement, 1)
    if ($footerPatched -eq $code) {
        throw 'bdo-data-extractor v0.1.7의 footer 검증 코드 위치를 찾지 못해 안전하게 호환 패치를 적용할 수 없습니다.'
    }
    $code = $footerPatched

    [System.IO.File]::WriteAllText($itemsGo, $code, (New-Object System.Text.UTF8Encoding($false)))

    Push-Location $srcDir
    try {
        $buildCompat = Invoke-NativeCommand $go @('build','-o',$compatExe,'.')
    } finally {
        Pop-Location
    }
    if ($buildCompat.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $compatExe)) {
        $log = $buildCompat.Output
        if (-not $log) { $log = '(추가 오류 메시지 없음)' }
        throw "호환 bdo-data-extractor 빌드에 실패했습니다. (종료 코드 $($buildCompat.ExitCode))`r`n`r`n$log"
    }
    return $compatExe
}

function Get-OfficialKoreanLoc([string]$GameDir) {
    # R9: 정식 한국어 LOC는 설치 폴더에 실제 파일이 있을 때만 사용합니다.
    # 한국 클라이언트는 설치 방식/패치 상태에 따라 별도 KR LOC가 없을 수 있으므로
    # 존재하지 않는 CDN 주소를 추측해서 내려받지 않습니다.
    $adsDir = Join-Path $GameDir 'ads'
    foreach ($name in @('languagedata_kr.loc','languagedata_ko.loc')) {
        $candidate = Join-Path $adsDir $name
        if ((Test-Path -LiteralPath $candidate) -and ((Get-Item -LiteralPath $candidate).Length -gt 1MB)) {
            $langName = ([IO.Path]::GetFileNameWithoutExtension($name) -replace '^languagedata_','')
            return [pscustomobject]@{Path=$candidate; Version='local'; Source='local-client'; Name=$name; Lang=$langName}
        }
    }
    throw "설치 폴더의 ads에서 languagedata_kr.loc / languagedata_ko.loc를 찾지 못했습니다. R9은 이 경우 PAZ 내부 itemenchant의 한국어 원본 이름을 사용합니다."
}

function New-ShadowGameRootForLoc([string]$GameDir, [string]$LocPath) {
    $shadow = Join-Path $env:TEMP ('BDO_KR_Loc_Game_' + [Guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $shadow -Force | Out-Null
    $ads = Join-Path $shadow 'ads'
    New-Item -ItemType Directory -Path $ads -Force | Out-Null
    Copy-Item -LiteralPath $LocPath -Destination (Join-Path $ads 'languagedata_en.loc') -Force
    $realPaz = Join-Path $GameDir 'Paz'
    $linkPaz = Join-Path $shadow 'Paz'
    try { New-Item -ItemType Junction -Path $linkPaz -Target $realPaz -Force | Out-Null }
    catch {
        $r = Invoke-NativeCommand $env:ComSpec @('/c','mklink','/J',$linkPaz,$realPaz)
        if ($r.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $linkPaz)) { throw "임시 Paz 연결을 만들지 못했습니다.`r`n$($r.Output)" }
    }
    return [pscustomobject]@{GameRoot=$shadow; ShadowRoot=$shadow}
}

function Get-LocNameField($Value) {
    if ($null -eq $Value) { return $null }
    if ($Value -is [string]) { return [string]$Value }
    if ($Value -is [System.Collections.IDictionary]) {
        foreach ($k in @('0','name','Name','text','Text')) {
            if ($Value.Contains($k) -and ($Value[$k] -is [string])) { return [string]$Value[$k] }
        }
    }
    if ($Value.PSObject) {
        foreach ($k in @('0','name','Name','text','Text')) {
            $pr = $Value.PSObject.Properties[$k]
            if ($pr -and ($pr.Value -is [string])) { return [string]$pr.Value }
            if ($pr -and $pr.Value -and $pr.Value.PSObject) {
                foreach ($tk in @('text','Text','value','Value')) {
                    $tpr=$pr.Value.PSObject.Properties[$tk]
                    if($tpr -and ($tpr.Value -is [string])) { return [string]$tpr.Value }
                }
            }
        }
        foreach ($pr in $Value.PSObject.Properties) {
            $n=0
            if ([int]::TryParse([string]$pr.Name,[ref]$n) -and $n -eq 0 -and ($pr.Value -is [string])) { return [string]$pr.Value }
        }
    }
    return $null
}

function New-KoreanItemNameMapFromOfficialLoc([string]$Extractor, [string]$GameDir, [string]$DestinationPath) {
    $loc = Get-OfficialKoreanLoc $GameDir
    $shadow = $null
    $outDir = Join-Path $env:TEMP ('BDO_KR_Loc_Dump_' + [Guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $outDir -Force | Out-Null
    try {
        $shadow = New-ShadowGameRootForLoc $GameDir $loc.Path
        $r = Invoke-NativeCommand $Extractor @('loc','--game',$shadow.GameRoot,'--out',$outDir,'--lang','en')
        if ($r.ExitCode -ne 0) { throw "한국어 LOC 해석에 실패했습니다.`r`n$($r.Output)" }
        $tablePath = Join-Path $outDir 'locs\0.json'
        $table = $null
        if (Test-Path -LiteralPath $tablePath) {
            $table = [System.IO.File]::ReadAllText($tablePath,[Text.Encoding]::UTF8) | ConvertFrom-Json
        } else {
            $fullPath = Join-Path $outDir 'loc_en.json'
            if (-not (Test-Path -LiteralPath $fullPath)) { throw 'LOC 출력에서 locs\\0.json 또는 loc_en.json을 찾지 못했습니다.' }
            $full = [System.IO.File]::ReadAllText($fullPath,[Text.Encoding]::UTF8) | ConvertFrom-Json
            $p0 = $full.PSObject.Properties['0']
            if ($p0) { $table=$p0.Value }
            if (-not $table -and $full.tables) {
                $p0=$full.tables.PSObject.Properties['0']
                if($p0){$table=$p0.Value}
            }
        }
        if (-not $table) { throw '한국어 LOC의 아이템 테이블(0)을 읽지 못했습니다.' }
        $map = [ordered]@{}
        foreach ($pr in $table.PSObject.Properties) {
            [uint32]$id=0
            if(-not [uint32]::TryParse([string]$pr.Name,[ref]$id)){continue}
            $name=Get-LocNameField $pr.Value
            if($name -and $name -match '[가-힣]'){ $map[[string]$id]=$name.Trim() }
        }
        if ($map.Count -eq 0) { throw '한국어 LOC에서 아이템 한국어 이름을 한 개도 찾지 못했습니다.' }
        $json = $map | ConvertTo-Json -Compress -Depth 8
        [System.IO.File]::WriteAllText($DestinationPath,$json,(New-Object Text.UTF8Encoding($false)))
        $script:LastKoNameSource = "$($loc.Source):$($loc.Name)"
        return $map.Count
    } finally {
        if($shadow){ Remove-ExtractorGameRoot $shadow }
        if(Test-Path -LiteralPath $outDir){ Remove-Item -LiteralPath $outDir -Recurse -Force -ErrorAction SilentlyContinue }
    }
}

function Get-OfficialEnglishLoc {
    # 한국 클라이언트에는 bdo-data-extractor가 요구하는 ads\languagedata_en.loc가 없습니다.
    # 게임 폴더에는 아무것도 쓰지 않고, Pearl Abyss NAEU 공식 CDN의 최신 영어 LOC를
    # 사용자 LocalAppData 캐시에 내려받아 "이름 해석용"으로만 사용합니다.
    $cacheRoot = Join-Path $env:LOCALAPPDATA 'BDORecipeCalculator\loc-cache'
    New-Item -ItemType Directory -Path $cacheRoot -Force | Out-Null

    try {
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    } catch {}

    $indexUrl = 'https://naeu-o-dn.playblackdesert.com/UploadData/ads_files'
    try {
        $wc = New-Object System.Net.WebClient
        $wc.Headers['User-Agent'] = 'BDORecipeCalculator/1.1'
        $indexText = $wc.DownloadString($indexUrl)
        $m = [regex]::Match($indexText, '(?mi)^languagedata_en\.loc\s+(\d+)\s*$')
        if (-not $m.Success) { throw '공식 ads_files 목록에서 languagedata_en.loc 버전을 찾지 못했습니다.' }
        $ver = $m.Groups[1].Value
        $cached = Join-Path $cacheRoot ("languagedata_en_{0}.loc" -f $ver)
        if ((Test-Path -LiteralPath $cached) -and ((Get-Item -LiteralPath $cached).Length -gt 1MB)) {
            return [pscustomobject]@{ Path=$cached; Version=$ver; Source='cache' }
        }

        $url = "https://naeu-o-dn.playblackdesert.com/UploadData/ads/languagedata_en/$ver/languagedata_en.loc"
        Show-Info "한국 검은사막 클라이언트에는 bdo-data-extractor가 필요로 하는 languagedata_en.loc 파일이 없습니다.`r`n`r`n이제 Pearl Abyss NAEU 공식 서버에서 최신 영어 현지화 파일을 임시 보조 파일로 내려받습니다. 이 파일은 추출기 호환용 보조 현지화 파일로만 사용합니다. R5 화면의 아이템명은 이후 게임 내부 한국어 원본 이름을 다시 읽어 적용하며, 선택한 검은사막 설치 폴더는 수정하지 않습니다.`r`n`r`n처음 한 번은 파일 크기 때문에 잠시 걸릴 수 있습니다."
        $tmp = $cached + '.download'
        if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
        $wc.DownloadFile($url, $tmp)
        if (-not (Test-Path -LiteralPath $tmp) -or ((Get-Item -LiteralPath $tmp).Length -lt 1MB)) {
            throw '다운로드된 languagedata_en.loc 파일이 비정상적으로 작거나 없습니다.'
        }
        Move-Item -LiteralPath $tmp -Destination $cached -Force
        return [pscustomobject]@{ Path=$cached; Version=$ver; Source='download' }
    } catch {
        $autoError = $_.Exception.Message
        $manual = Ask-YesNo "공식 영어 현지화 파일 자동 다운로드에 실패했습니다.`r`n`r`n$autoError`r`n`r`n이미 가지고 있는 languagedata_en.loc 파일을 직접 선택해서 계속하시겠습니까?"
        if (-not $manual) { throw "한국 클라이언트용 보조 현지화 파일을 준비하지 못했습니다.`r`n$autoError" }
        $picked = Pick-File 'languagedata_en.loc 파일을 선택하세요.' 'Black Desert localization (languagedata_en.loc)|languagedata_en.loc|LOC 파일 (*.loc)|*.loc'
        if (-not $picked) { throw '보조 현지화 파일 선택을 취소했습니다.' }
        return [pscustomobject]@{ Path=$picked; Version='manual'; Source='manual' }
    }
}

function Prepare-ExtractorGameRoot([string]$GameDir) {
    # R9은 정식 한국어 LOC를 최우선 사용합니다. 이를 임시 가상 루트에서
    # languagedata_en.loc라는 이름으로 읽혀 extractor가 items.json 자체를 한국어로 만들게 합니다.
    $loc = $null
    try { $loc = Get-OfficialKoreanLoc $GameDir } catch { $script:KoLocPrepareError = $_.Exception.Message }
    if (-not $loc) {
        $nativeEn = Join-Path $GameDir 'ads\languagedata_en.loc'
        if ((Test-Path -LiteralPath $nativeEn) -and ((Get-Item -LiteralPath $nativeEn).Length -gt 1MB)) {
            $loc = [pscustomobject]@{Path=$nativeEn;Version='local';Source='native-en';Name='languagedata_en.loc';Lang='en'}
        } else {
            $en = Get-OfficialEnglishLoc
            $loc = [pscustomobject]@{Path=$en.Path;Version=$en.Version;Source=('fallback-en:'+ $en.Source);Name='languagedata_en.loc';Lang='en'}
        }
    }
    $shadow = New-ShadowGameRootForLoc $GameDir $loc.Path
    return [pscustomobject]@{GameRoot=$shadow.GameRoot;ShadowRoot=$shadow.ShadowRoot;UsedFallback=$true;LocPath=$loc.Path;LocVersion=$loc.Version;LocalizationSource=("$($loc.Source):$($loc.Name)");LocaleKind=$loc.Lang}
}

function Remove-ExtractorGameRoot($Prepared) {
    if (-not $Prepared -or -not $Prepared.ShadowRoot) { return }
    $shadow = [string]$Prepared.ShadowRoot
    $pazLink = Join-Path $shadow 'Paz'
    if (Test-Path -LiteralPath $pazLink) {
        # Junction 대상(실제 게임 Paz)을 지우지 않도록 링크 자체만 rmdir로 제거합니다.
        try { & $env:ComSpec /c rmdir "$pazLink" 2>$null | Out-Null } catch {}
    }
    if (Test-Path -LiteralPath $shadow) {
        Remove-Item -LiteralPath $shadow -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Get-RecipeItemIds([string]$RecipeJsonPath) {
    $set = New-Object 'System.Collections.Generic.HashSet[uint32]'
    $raw = [System.IO.File]::ReadAllText($RecipeJsonPath, [System.Text.Encoding]::UTF8)
    $rows = $raw | ConvertFrom-Json
    foreach ($r in $rows) {
        $refs = @()
        if ($r.output) { $refs += $r.output }
        if ($r.inputs) {
            foreach ($x in $r.inputs) {
                if ($x.item) { $refs += $x.item }
            }
        }
        foreach ($ref in $refs) {
            $s = [string]$ref
            if ($s -match 'item:(\d+)$') {
                [void]$set.Add([uint32]$matches[1])
            }
        }
    }
    return ,$set
}

function Get-U32([byte[]]$Bytes, [int]$Offset) {
    return [System.BitConverter]::ToUInt32($Bytes, $Offset)
}

function Get-EmbeddedKoreanItemName([byte[]]$Data, [int]$RowOffset, [int]$RowSize) {
    if ($RowSize -lt 220 -or $RowOffset -lt 0 -or ($RowOffset + $RowSize) -gt $Data.Length) { return $null }

    # itemenchant Name/Icon 규칙: [i64 nameChars][UTF-16LE name][i64 iconLen][ASCII icon].
    # 아이콘 마커를 찾은 뒤 길이 prefix가 실제 마커와 일치하는지 검증하고, 그 앞의 UTF-16 이름을 역탐색합니다.
    $ascii = [System.Text.Encoding]::ASCII.GetString($Data, $RowOffset, $RowSize)
    $searchFrom = 0
    while ($searchFrom -lt $ascii.Length) {
        $a = $ascii.IndexOf('New_Icon', $searchFrom, [System.StringComparison]::OrdinalIgnoreCase)
        $b = $ascii.IndexOf('new_icon', $searchFrom, [System.StringComparison]::OrdinalIgnoreCase)
        if ($a -lt 0) { $rel = $b } elseif ($b -lt 0) { $rel = $a } else { $rel = [Math]::Min($a,$b) }
        if ($rel -lt 0) { break }
        $searchFrom = $rel + 1
        $iconPos = $RowOffset + $rel
        if ($iconPos -lt ($RowOffset + 8)) { continue }
        $iconLen = [System.BitConverter]::ToInt64($Data, $iconPos - 8)
        if ($iconLen -lt 8 -or $iconLen -gt 512 -or ($iconPos + $iconLen) -gt ($RowOffset + $RowSize)) { continue }
        $iconText = [System.Text.Encoding]::UTF8.GetString($Data, $iconPos, [int]$iconLen)
        if ($iconText -notmatch '(?i)^new_icon.*\.dds$') { continue }

        $nameEnd = $iconPos - 8
        for ($nameLen = 1; $nameLen -le 180; $nameLen++) {
            $prefix = $nameEnd - 8 - ($nameLen * 2)
            if ($prefix -lt ($RowOffset + 4)) { break }
            $declared = [System.BitConverter]::ToInt64($Data, $prefix)
            if ($declared -ne $nameLen) { continue }
            $name = [System.Text.Encoding]::Unicode.GetString($Data, $prefix + 8, $nameLen * 2).Trim([char]0).Trim()
            if (-not $name) { continue }
            # 게임 내부 원본 Name은 한국어입니다. 오탐 문자열은 제외합니다.
            if ($name -match '[가-힣]' -and $name -notmatch '[\x00-\x08\x0B\x0C\x0E-\x1F]') { return $name }
        }
    }
    return $null
}

function Escape-JsonString([string]$Value) {
    if ($null -eq $Value) { return '' }
    return $Value.Replace('\','\\').Replace('"','\"').Replace("`b",'\b').Replace("`f",'\f').Replace("`n",'\n').Replace("`r",'\r').Replace("`t",'\t')
}

function New-KoreanSourceItemNameMap([string]$Extractor, [string]$GameRoot, [string]$RecipeJsonPath, [string]$DestinationPath) {
    $wanted = $null
    if ($RecipeJsonPath) {
        $wanted = Get-RecipeItemIds $RecipeJsonPath
        if ($wanted.Count -eq 0) { throw 'recipes.json에서 아이템 ID를 찾지 못했습니다.' }
    }

    $rawDir = Join-Path $env:TEMP ('BDO_KO_Name_Extract_' + [Guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $rawDir -Force | Out-Null
    try {
        $r = Invoke-NativeCommand $Extractor @('extract','--game',$GameRoot,'itemenchant',$rawDir)
        if ($r.ExitCode -ne 0) {
            throw "itemenchant 원본 추출에 실패했습니다.`r`n선택한 게임 폴더: $GameRoot`r`n실행 형식: extract --game <게임폴더> itemenchant <임시폴더>`r`n`r`n$($r.Output)"
        }

        $dataFile = Get-ChildItem -LiteralPath $rawDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq 'itemenchant.dbss' } | Select-Object -First 1
        $offsetFile = Get-ChildItem -LiteralPath $rawDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq 'itemenchantoffset.dbss' } | Select-Object -First 1
        if (-not $dataFile -or -not $offsetFile) {
            throw '추출된 파일에서 itemenchant.dbss / itemenchantoffset.dbss를 찾지 못했습니다.'
        }

        $data = [System.IO.File]::ReadAllBytes($dataFile.FullName)
        $idx = [System.IO.File]::ReadAllBytes($offsetFile.FullName)
        if ($idx.Length -lt 16) { throw 'itemenchantoffset.dbss가 너무 작습니다.' }

        $hdr = 4
        $count = Get-U32 $idx 0
        if ($idx.Length -ge 8 -and [System.Text.Encoding]::ASCII.GetString($idx,0,4) -eq 'PABR') {
            $hdr = 8
            $count = Get-U32 $idx 4
        }
        if (($hdr + ([int64]$count * 12)) -gt $idx.Length) {
            throw "itemenchant offset 인덱스 크기가 비정상입니다. count=$count"
        }

        # 12바이트 인덱스의 세 u32 열은 테이블별로 순서가 달라질 수 있으므로
        # 실제 레코드 범위 + 레코드 첫 u32(self id) 일치율로 key/offset/size 순서를 자동 판별합니다.
        $perms = @(
            @(0,1,2), @(0,2,1), @(1,0,2),
            @(1,2,0), @(2,0,1), @(2,1,0)
        )
        $sample = [Math]::Min([int]$count, 2500)
        $bestPerm = $null
        $bestScore = -1
        foreach ($perm in $perms) {
            $score = 0
            for ($i=0; $i -lt $sample; $i++) {
                $b = $hdr + ($i * 12)
                $v = @((Get-U32 $idx $b),(Get-U32 $idx ($b+4)),(Get-U32 $idx ($b+8)))
                $key = [uint32]$v[$perm[0]]
                $off = [uint32]$v[$perm[1]]
                $size = [uint32]$v[$perm[2]]
                if ($size -gt 0 -and ([uint64]$off + [uint64]$size) -le [uint64]$data.Length) {
                    $score += 1
                    if ($key -lt 10000000 -and $size -ge 4 -and (Get-U32 $data ([int]$off)) -eq $key) {
                        $score += 20
                    }
                }
            }
            if ($score -gt $bestScore) {
                $bestScore = $score
                $bestPerm = $perm
            }
        }
        if (-not $bestPerm -or $bestScore -le 0) { throw 'itemenchant offset 열 순서를 판별하지 못했습니다.' }

        $utf8 = New-Object System.Text.UTF8Encoding($false)
        $sw = New-Object System.IO.StreamWriter($DestinationPath, $false, $utf8)
        $written = 0
        try {
            $sw.Write('{')
            for ($i=0; $i -lt [int]$count; $i++) {
                $b = $hdr + ($i * 12)
                $v = @((Get-U32 $idx $b),(Get-U32 $idx ($b+4)),(Get-U32 $idx ($b+8)))
                $key = [uint32]$v[$bestPerm[0]]
                if ($key -ge 10000000) { continue }
                if ($wanted -and -not $wanted.Contains($key)) { continue }
                $off = [uint32]$v[$bestPerm[1]]
                $size = [uint32]$v[$bestPerm[2]]
                if ($size -lt 4 -or ([uint64]$off + [uint64]$size) -gt [uint64]$data.Length) { continue }
                if ((Get-U32 $data ([int]$off)) -ne $key) { continue }

                $name = Get-EmbeddedKoreanItemName $data ([int]$off) ([int]$size)
                if (-not $name) { continue }
                if ($written -gt 0) { $sw.Write(',') }
                $sw.Write('"')
                $sw.Write($key.ToString())
                $sw.Write('":"')
                $sw.Write((Escape-JsonString $name))
                $sw.Write('"')
                $written++
            }
            $sw.Write('}')
        } finally {
            $sw.Dispose()
        }

        return $written
    } finally {
        if (Test-Path -LiteralPath $rawDir) {
            Remove-Item -LiteralPath $rawDir -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

function New-KoreanItemNameMap([string]$Extractor, [string]$GameRoot, [string]$RecipeJsonPath, [string]$DestinationPath) {
    # R9: 한국 클라이언트에서 ads\languagedata_kr.loc가 실제로 존재하는 경우에만
    # 정식 LOC를 우선 사용합니다. 한국 설치본에 LOC가 별도 파일로 없으면 네트워크에서
    # 임의 URL을 추측해 받지 않고, PAZ의 itemenchant.dbss 내부 한국어 Name을 직접 읽습니다.
    # FORMATS.md 기준 itemenchant Name은 클라이언트 원본 한국어 UTF-16 문자열입니다.
    $localLoc = $null
    $adsDir = Join-Path $GameRoot 'ads'
    foreach ($name in @('languagedata_kr.loc','languagedata_ko.loc')) {
        $candidate = Join-Path $adsDir $name
        if ((Test-Path -LiteralPath $candidate) -and ((Get-Item -LiteralPath $candidate).Length -gt 1MB)) {
            $localLoc = $candidate
            break
        }
    }

    $locError = $null
    if ($localLoc) {
        try {
            # New-KoreanItemNameMapFromOfficialLoc 내부 Get-OfficialKoreanLoc가 로컬 파일을 먼저 사용합니다.
            $n = New-KoreanItemNameMapFromOfficialLoc $Extractor $GameRoot $DestinationPath
            $script:LastKoNameSource = 'local-client-korean-loc'
            if ($n -gt 0) { return $n }
        } catch { $locError = $_.Exception.Message }
    }

    try {
        $n = New-KoreanSourceItemNameMap $Extractor $GameRoot $RecipeJsonPath $DestinationPath
        $script:LastKoNameSource = 'client-itemenchant-korean-source'
        if ($n -gt 0) { return $n }
        throw 'itemenchant에서 한국어 이름을 찾았지만 결과가 0개였습니다.'
    } catch {
        $sourceError = $_.Exception.Message
        if ($locError) {
            throw "한국어 이름 적용에 실패했습니다.`r`n[설치 폴더 KR LOC] $locError`r`n[클라이언트 itemenchant 한국어 원본] $sourceError"
        }
        throw "한국어 이름 적용에 실패했습니다.`r`n[클라이언트 itemenchant 한국어 원본] $sourceError"
    }
}

function Send-Headers($Stream, [int]$StatusCode, [string]$StatusText, [string]$ContentType, [long]$ContentLength) {
    $header = "HTTP/1.1 $StatusCode $StatusText`r`nContent-Type: $ContentType`r`nContent-Length: $ContentLength`r`nAccess-Control-Allow-Origin: *`r`nAccess-Control-Allow-Methods: GET, POST, OPTIONS`r`nAccess-Control-Allow-Headers: Content-Type`r`nCache-Control: no-store`r`nConnection: close`r`n`r`n"
    $bytes = [System.Text.Encoding]::ASCII.GetBytes($header)
    $Stream.Write($bytes, 0, $bytes.Length)
}
function Send-Bytes($Stream, [byte[]]$Bytes, [string]$ContentType='application/octet-stream', [int]$StatusCode=200, [string]$StatusText='OK') {
    Send-Headers $Stream $StatusCode $StatusText $ContentType $Bytes.LongLength
    if ($Bytes.Length -gt 0) { $Stream.Write($Bytes,0,$Bytes.Length) }
    try { $Stream.Flush() } catch {}
}
function Send-Text($Stream, [string]$Text, [string]$ContentType='text/plain; charset=utf-8', [int]$StatusCode=200, [string]$StatusText='OK') {
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
    Send-Bytes $Stream $bytes $ContentType $StatusCode $StatusText
}
function Send-Json($Stream, $Object, [int]$StatusCode=200, [string]$StatusText='OK') {
    $json = $Object | ConvertTo-Json -Depth 8 -Compress
    Send-Text $Stream $json 'application/json; charset=utf-8' $StatusCode $StatusText
}
function Send-File($Stream, [string]$Path, [string]$ContentType) {
    if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
        Send-Json $Stream @{ok=$false; error='파일을 찾지 못했습니다.'} 404 'Not Found'
        return
    }
    $fi = Get-Item -LiteralPath $Path
    Send-Headers $Stream 200 'OK' $ContentType $fi.Length
    $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
    try { $fs.CopyTo($Stream); try { $Stream.Flush() } catch {} } finally { $fs.Dispose() }
}

function Handle-KoreanNames($Stream) {
    try {
        $defaultGame = 'C:\Pearlabyss\BlackDesert'
        if (-not (Test-Path -LiteralPath $defaultGame)) {
            $steam = 'C:\Program Files (x86)\Steam\steamapps\common\Black Desert Online'
            if (Test-Path -LiteralPath $steam) { $defaultGame = $steam } else { $defaultGame = '' }
        }
        $gameDir = Pick-Folder '한국어 아이템명을 읽을 검은사막 설치 폴더를 선택하세요. 이 폴더 안에 Paz\pad00000.meta 파일이 있어야 합니다.' $defaultGame
        if (-not $gameDir) { Send-Json $Stream @{ok=$false; cancelled=$true}; return }
        $meta = Join-Path $gameDir 'Paz\pad00000.meta'
        if (-not (Test-Path -LiteralPath $meta)) {
            throw "선택한 폴더에서 Paz\pad00000.meta를 찾지 못했습니다.`r`n검은사막 설치 폴더의 최상위 폴더를 선택해 주세요."
        }

        $saveDir = Join-Path $env:LOCALAPPDATA 'BDORecipeCalculator\korean-cache'
        New-Item -ItemType Directory -Path $saveDir -Force | Out-Null
        $destKoNames = Join-Path $saveDir 'item_names_ko.json'
        $extractor = Ensure-Extractor

        # recipes.json을 다시 고르게 하지 않고 itemenchant의 모든 실제 아이템 Name을 읽습니다.
        $count = New-KoreanItemNameMap $extractor $gameDir $null $destKoNames
        if ($count -le 0 -or -not (Test-Path -LiteralPath $destKoNames)) {
            throw '게임 데이터에서 한국어 아이템명을 찾지 못했습니다. 최신 R9 도우미로 다시 시도해 주세요.'
        }
        $script:LastKoNamesPath = $destKoNames
        Send-Json $Stream @{ok=$true; koNamesPath=$destKoNames; koNameCount=$count; saveDir=$saveDir; koNameSource=$script:LastKoNameSource}
    } catch {
        $msg = $_.Exception.Message
        Show-ErrorBox $msg
        Send-Json $Stream @{ok=$false; error=$msg} 500 'Internal Server Error'
    }
}


function Handle-SelectExtract($Stream) {
    try {
        $defaultGame = 'C:\Program Files (x86)\Steam\steamapps\common\Black Desert Online'
        if (-not (Test-Path -LiteralPath $defaultGame)) {
            $krDefault = 'C:\Pearlabyss\BlackDesert'
            if (Test-Path -LiteralPath $krDefault) { $defaultGame = $krDefault } else { $defaultGame = '' }
        }
        $gameDir = Pick-Folder '검은사막 설치 폴더를 선택하세요. 이 폴더 안에 Paz\pad00000.meta 파일이 있어야 합니다.' $defaultGame
        if (-not $gameDir) {
            $script:PendingGameDir = $null
            Send-Json $Stream @{ok=$false; cancelled=$true}
            return
        }

        $meta = Join-Path $gameDir 'Paz\pad00000.meta'
        if (-not (Test-Path -LiteralPath $meta)) {
            throw "선택한 폴더에서 Paz\pad00000.meta를 찾지 못했습니다.`r`n검은사막 설치 폴더의 최상위 폴더를 선택해 주세요."
        }

        $replaceMsg = "새로 생성한 검은사막 DB로 현재 계산기 폴더의 기존 DB를 덮어씁니다.`r`n`r`n대상 폴더:`r`n$BaseDir`r`n`r`n교체 파일:`r`n- items.json`r`n- recipes.json`r`n- item_names_ko.json`r`n`r`n확인을 누르면 데이터 업데이트가 바로 시작됩니다."
        if (-not (Ask-OKCancel $replaceMsg 'DB 덮어쓰기 확인')) {
            $script:PendingGameDir = $null
            Send-Json $Stream @{ok=$false; cancelled=$true}
            return
        }

        $writeTest = Join-Path $BaseDir ('.bdo_write_test_' + [Guid]::NewGuid().ToString('N') + '.tmp')
        try {
            [System.IO.File]::WriteAllText($writeTest,'ok')
            Remove-Item -LiteralPath $writeTest -Force
        } catch {
            throw "계산기 프로그램 폴더에 DB를 저장할 권한이 없습니다.`r`n`r`n$BaseDir`r`n`r`n압축을 바탕 화면/문서 등 쓰기 가능한 폴더에 풀어서 다시 실행해 주세요."
        }

        $script:PendingGameDir = $gameDir
        Send-Json $Stream @{ok=$true; gameDir=$gameDir; targetDir=$BaseDir}
    } catch {
        $script:PendingGameDir = $null
        $msg = $_.Exception.Message
        Show-ErrorBox $msg
        Send-Json $Stream @{ok=$false; error=$msg} 500 'Internal Server Error'
    }
}

function Handle-Extract($Stream, [string]$PreselectedGameDir='') {
    $preparedGame = $null
    try {
        if ($PreselectedGameDir) {
            $gameDir = $PreselectedGameDir
        } else {
            $defaultGame = 'C:\Program Files (x86)\Steam\steamapps\common\Black Desert Online'
            if (-not (Test-Path -LiteralPath $defaultGame)) {
                $krDefault = 'C:\Pearlabyss\BlackDesert'
                if (Test-Path -LiteralPath $krDefault) { $defaultGame = $krDefault } else { $defaultGame = '' }
            }
            $gameDir = Pick-Folder '검은사막 설치 폴더를 선택하세요. 이 폴더 안에 Paz\pad00000.meta 파일이 있어야 합니다.' $defaultGame
            if (-not $gameDir) { Send-Json $Stream @{ok=$false; cancelled=$true}; return }

            $replaceMsg = "새로 생성한 검은사막 DB로 현재 계산기 폴더의 기존 DB를 덮어씁니다.`r`n`r`n대상 폴더:`r`n$BaseDir`r`n`r`n교체 파일:`r`n- items.json`r`n- recipes.json`r`n- item_names_ko.json`r`n`r`n계속하려면 확인을 누르세요."
            if (-not (Ask-OKCancel $replaceMsg 'DB 덮어쓰기 확인')) {
                Send-Json $Stream @{ok=$false; cancelled=$true}; return
            }
        }

        $meta = Join-Path $gameDir 'Paz\pad00000.meta'
        if (-not (Test-Path -LiteralPath $meta)) {
            throw "선택한 폴더에서 Paz\pad00000.meta를 찾지 못했습니다.`r`n검은사막 설치 폴더의 최상위 폴더를 선택해 주세요."
        }

        # R14: 저장 폴더는 묻지 않고 프로그램 자체 DB를 안전하게 교체합니다.
        $saveDir = $BaseDir
        $destItems = $BundledItemsPath
        $destRecipes = $BundledRecipesPath
        $destKoNames = $BundledKoPath

        $extractor = Ensure-Extractor
        $preparedGame = Prepare-ExtractorGameRoot $gameDir
        $extractGameDir = $preparedGame.GameRoot

        $tempOut = Join-Path $env:TEMP ('BDO_Recipe_Extract_' + [Guid]::NewGuid().ToString('N'))
        $koNameCount = 0
        $koNameError = $null
        New-Item -ItemType Directory -Path $tempOut -Force | Out-Null
        try {
            # bdo-data-extractor v0.1.7은 en/de/fr/sp LOC만 지원합니다.
            # 한국 클라이언트일 때는 Prepare-ExtractorGameRoot가 실제 Paz + 임시 영어 LOC의 가상 루트를 제공합니다.
            $buildResult = Invoke-NativeCommand $extractor @('build','--game',$extractGameDir,'--out',$tempOut,'--lang','en')
            $exit = $buildResult.ExitCode
            $buildLog = $buildResult.Output

            # 2026-08 live Korean client: v0.1.7 can abort on a newly changed,
            # non-essential post-icon/footer item metadata block. Retry with a narrowly
            # patched compatibility build that relaxes only those tail guards.
            if ($exit -ne 0 -and $buildLog -match 'post-icon layout changed|invalid footer') {
                $compatExtractor = Ensure-CompatibilityExtractor
                if (Test-Path -LiteralPath $tempOut) {
                    Remove-Item -LiteralPath $tempOut -Recurse -Force -ErrorAction SilentlyContinue
                }
                New-Item -ItemType Directory -Path $tempOut -Force | Out-Null
                $buildResult = Invoke-NativeCommand $compatExtractor @('build','--game',$extractGameDir,'--out',$tempOut,'--lang','en')
                $exit = $buildResult.ExitCode
                $buildLog = $buildResult.Output
            }

            if ($exit -ne 0) {
                if (-not $buildLog) { $buildLog = '(추가 오류 메시지 없음)' }
                throw "bdo-data-extractor 실행 실패 (종료 코드 $exit)`r`n`r`n$buildLog"
            }
            $srcItems = Join-Path $tempOut 'items.json'
            $srcRecipes = Join-Path $tempOut 'recipes.json'
            $srcKoNames = Join-Path $tempOut 'item_names_ko.json'
            if (-not (Test-Path -LiteralPath $srcItems)) { throw '추출 결과에 items.json이 없습니다.' }
            if (-not (Test-Path -LiteralPath $srcRecipes)) { throw '추출 결과에 recipes.json이 없습니다.' }

            # R11: 한국어 이름까지 임시 폴더에서 모두 성공한 뒤에만 기존 프로그램 DB 3개를 교체합니다.
            # 중간 실패 시 기존 DB를 그대로 보존합니다.
            $koNameCount = New-KoreanItemNameMap $extractor $gameDir $null $srcKoNames
            if ($koNameCount -le 0 -or -not (Test-Path -LiteralPath $srcKoNames)) {
                throw '한국어 아이템명 생성에 실패하여 기존 프로그램 DB는 교체하지 않았습니다.'
            }

            Copy-Item -LiteralPath $srcItems -Destination $destItems -Force
            Copy-Item -LiteralPath $srcRecipes -Destination $destRecipes -Force
            Copy-Item -LiteralPath $srcKoNames -Destination $destKoNames -Force
        } finally {
            if (Test-Path -LiteralPath $tempOut) { Remove-Item -LiteralPath $tempOut -Recurse -Force -ErrorAction SilentlyContinue }
        }

        $script:LastItemsPath = $destItems
        $script:LastRecipesPath = $destRecipes
        $script:LastKoNamesPath = if (Test-Path -LiteralPath $destKoNames) { $destKoNames } else { $null }
        if ($koNameCount -gt 0) {
            $fallbackText = "한국어 아이템명 $koNameCount개 적용 준비 완료"
        } else {
            $fallbackText = '한국어 이름 매핑 없음'
        }
        Send-Json $Stream @{ok=$true; saveDir=$saveDir; itemsPath=$destItems; recipesPath=$destRecipes; koNamesPath=$script:LastKoNamesPath; koNameCount=$koNameCount; localizationMode=$fallbackText; locSource=$preparedGame.LocalizationSource; koNameSource=$script:LastKoNameSource}
    } catch {
        $msg = $_.Exception.Message
        Show-ErrorBox $msg
        Send-Json $Stream @{ok=$false; error=$msg} 500 'Internal Server Error'
    } finally {
        Remove-ExtractorGameRoot $preparedGame
        if ($PreselectedGameDir) { $script:PendingGameDir = $null }
    }
}

function Find-AppBrowser {
    $candidates = @()
    $cmd = Get-Command msedge.exe -ErrorAction SilentlyContinue
    if ($cmd) { $candidates += [pscustomobject]@{Path=$cmd.Source; Name='msedge.exe'} }
    foreach ($c in @(
        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
    )) { if ($c -and (Test-Path -LiteralPath $c)) { $candidates += [pscustomobject]@{Path=$c;Name='msedge.exe'} } }
    $chrome = Get-Command chrome.exe -ErrorAction SilentlyContinue
    if ($chrome) { $candidates += [pscustomobject]@{Path=$chrome.Source;Name='chrome.exe'} }
    foreach ($c in @(
        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
    )) { if ($c -and (Test-Path -LiteralPath $c)) { $candidates += [pscustomobject]@{Path=$c;Name='chrome.exe'} } }
    return $candidates | Select-Object -First 1
}

function Get-CalculatorBrowserProcesses([string]$ProcessName, [string]$ProfileDir) {
    if (-not $ProcessName -or -not $ProfileDir) { return @() }
    try {
        $escapedProfile = $ProfileDir.Replace('\\','\')
        $all = Get-CimInstance Win32_Process -Filter ("Name='" + $ProcessName.Replace("'","''") + "'") -ErrorAction Stop
        return @($all | Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($escapedProfile,[System.StringComparison]::OrdinalIgnoreCase) -ge 0 })
    } catch {
        return @()
    }
}

function Test-CalculatorWindowOpen([string]$ProcessName, [string]$ProfileDir) {
    $ps = Get-CalculatorBrowserProcesses $ProcessName $ProfileDir
    foreach ($wp in $ps) {
        try {
            $gp = Get-Process -Id $wp.ProcessId -ErrorAction Stop
            if ($gp.MainWindowHandle -ne 0) { return $true }
        } catch {}
    }
    return $false
}

function Stop-CalculatorBrowser([string]$ProcessName, [string]$ProfileDir) {
    foreach ($wp in (Get-CalculatorBrowserProcesses $ProcessName $ProfileDir)) {
        try { Stop-Process -Id $wp.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
    }
}

function Start-CalculatorAppWindow([string]$Url) {
    # R11: Windows에 등록된 기본 브라우저를 사용합니다. Edge/Chrome을 강제로 선택하지 않습니다.
    Start-Process -FilePath $Url | Out-Null
    return [pscustomobject]@{Dedicated=$false; ProcessName=''; ProfileDir=''; StartTime=[DateTime]::UtcNow}
}

function Wait-PortReleased([int]$WaitMs=6000) {
    $loops = [Math]::Max(1,[int]($WaitMs/200))
    for ($i=0; $i -lt $loops; $i++) {
        Start-Sleep -Milliseconds 200
        try {
            $tcp = New-Object System.Net.Sockets.TcpClient
            $ar = $tcp.BeginConnect('127.0.0.1',$Port,$null,$null)
            $ok = $ar.AsyncWaitHandle.WaitOne(100)
            if ($ok -and $tcp.Connected) { $tcp.Close(); continue }
            try { $tcp.Close() } catch {}
            return $true
        } catch { return $true }
    }
    return $false
}

function Try-ShutdownExistingBdoHelper {
    try {
        $running = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/status?ts=$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())" -TimeoutSec 2
        if ($running -and [string]$running.helper -eq 'BDO Calculator Helper') {
            try { Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/shutdown?ts=$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())" -TimeoutSec 2 | Out-Null } catch {}
            if (Wait-PortReleased 6000) { return $true }
        }
    } catch {}

    # 응답하지 않는 구버전 PowerShell 도우미가 8765 포트를 붙잡고 있을 때만 소유 프로세스를 확인해 종료합니다.
    try {
        $conn = Get-NetTCPConnection -LocalAddress 127.0.0.1 -LocalPort $Port -State Listen -ErrorAction Stop | Select-Object -First 1
        if ($conn -and $conn.OwningProcess) {
            $wp = Get-CimInstance Win32_Process -Filter ("ProcessId=" + [int]$conn.OwningProcess) -ErrorAction Stop
            if ($wp -and $wp.CommandLine -and $wp.CommandLine -match 'BDO_Calculator_Helper\.ps1') {
                Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
                if (Wait-PortReleased 4000) { return $true }
            }
        }
    } catch {}
    return $false
}

if (-not (Test-Path -LiteralPath $HtmlPath)) {
    Show-ErrorBox "계산기 HTML 파일을 찾지 못했습니다.`r`n$HtmlPath"
    exit 1
}

$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
try {
    $listener.Start()
} catch {
    # 이전 R8/R9/R10/R11 도우미가 비정상적으로 남은 경우 자동 종료 후 한 번 재시도합니다.
    $closed = Try-ShutdownExistingBdoHelper
    if ($closed) {
        try {
            $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
            $listener.Start()
        } catch {
            Show-ErrorBox "이전 BDO 계산기 도우미를 종료했지만 포트 $Port 를 다시 열지 못했습니다.`r`n`r`n$($_.Exception.Message)"
            exit 1
        }
    } else {
        Show-ErrorBox "로컬 도우미를 시작하지 못했습니다. 포트 $Port 가 다른 프로그램에서 사용 중일 수 있습니다.`r`n`r`n$($_.Exception.Message)"
        exit 1
    }
}

$url = "http://127.0.0.1:$Port/"
$browserSession = Start-CalculatorAppWindow $url
$script:BrowserWindowSeen = $false
$script:BrowserMissingSince = $null
$script:LastBrowserCheck = [DateTime]::MinValue

try {
    while (-not $script:StopRequested) {
        $now = [DateTime]::UtcNow
        if ($browserSession.Dedicated -and (($now - $script:LastBrowserCheck).TotalMilliseconds -ge 750)) {
            $script:LastBrowserCheck = $now
            $open = Test-CalculatorWindowOpen $browserSession.ProcessName $browserSession.ProfileDir
            if ($open) {
                $script:BrowserWindowSeen = $true
                $script:BrowserMissingSince = $null
            } elseif ($script:BrowserWindowSeen) {
                if (-not $script:BrowserMissingSince) { $script:BrowserMissingSince = $now }
                elseif (($now - $script:BrowserMissingSince).TotalSeconds -ge 1.5) {
                    # 사용자가 전용 계산기 창을 닫았습니다. 이 프로필의 잔여 브라우저 프로세스까지 정리하고 도우미도 종료합니다.
                    Stop-CalculatorBrowser $browserSession.ProcessName $browserSession.ProfileDir
                    break
                }
            } elseif (($now - $browserSession.StartTime).TotalSeconds -gt 20) {
                # 전용 창이 20초 안에 한 번도 뜨지 못한 경우 무한 대기를 피합니다.
                Show-ErrorBox '계산기 브라우저 창을 확인하지 못했습니다. 실행 파일을 다시 실행해 주세요.'
                break
            }
        }

        if (-not $listener.Pending()) {
            # R10 기본 브라우저 모드: 탭/창이 닫히면 pagehide 신호 후 heartbeat가 끊깁니다.
            # 새로고침은 곧 heartbeat가 다시 들어오므로 종료 후보가 자동 취소됩니다.
            if ($script:PageCloseCandidate -and (([DateTime]::UtcNow - $script:PageCloseCandidate).TotalSeconds -ge 1.2)) { break }
            if ($script:HeartbeatSeen -and (([DateTime]::UtcNow - $script:LastHeartbeat).TotalSeconds -gt 60)) { break }
            if (-not $script:HeartbeatSeen -and (([DateTime]::UtcNow - $browserSession.StartTime).TotalSeconds -gt 45)) {
                Show-ErrorBox '기본 브라우저에서 계산기 페이지가 열리지 않았습니다. 기본 브라우저 설정을 확인한 뒤 실행 파일을 다시 실행해 주세요.'
                break
            }
            Start-Sleep -Milliseconds 50
            continue
        }
        $client = $listener.AcceptTcpClient()
        try {
            $stream = $client.GetStream()
            $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::ASCII, $false, 8192, $true)
            $requestLine = $reader.ReadLine()
            if (-not $requestLine) { continue }
            $parts = $requestLine.Split(' ')
            $method = $parts[0]
            $rawTarget = $parts[1]
            $path = ($rawTarget -split '\?')[0]
            $contentLength = 0
            while ($true) {
                $line = $reader.ReadLine()
                if ($null -eq $line -or $line -eq '') { break }
                if ($line -match '^Content-Length:\s*(\d+)') { $contentLength = [int]$matches[1] }
            }
            if ($contentLength -gt 0) {
                $buffer = New-Object char[] $contentLength
                [void]$reader.ReadBlock($buffer,0,$contentLength)
            }

            if ($method -eq 'OPTIONS') {
                Send-Bytes $stream ([byte[]]@()) 'text/plain' 204 'No Content'
            } elseif ($path -eq '/' -or $path -eq '/index.html') {
                Send-File $stream $HtmlPath 'text/html; charset=utf-8'
            } elseif ($path -eq '/api/status') {
                $ext = Find-Extractor
                Send-Json $stream @{ok=$true; helper='BDO Calculator Helper'; version='R14'; extractorReady=[bool]$ext; extractorPath=$ext; bundledData=((Test-Path $BundledItemsPath) -and (Test-Path $BundledRecipesPath) -and (Test-Path $BundledKoPath)); dedicatedWindow=[bool]$browserSession.Dedicated}
            } elseif ($path -eq '/api/heartbeat') {
                $script:LastHeartbeat = [DateTime]::UtcNow
                $script:HeartbeatSeen = $true
                $script:PageCloseCandidate = $null
                Send-Json $stream @{ok=$true}
            } elseif ($path -eq '/api/page-closing') {
                $script:PageCloseCandidate = [DateTime]::UtcNow
                Send-Json $stream @{ok=$true}
            } elseif ($path -eq '/api/shutdown') {
                Send-Json $stream @{ok=$true; message='shutdown'}
                $script:StopRequested = $true
            } elseif ($path -eq '/api/bundled/items') {
                Send-File $stream $BundledItemsPath 'application/json; charset=utf-8'
            } elseif ($path -eq '/api/bundled/recipes') {
                Send-File $stream $BundledRecipesPath 'application/json; charset=utf-8'
            } elseif ($path -eq '/api/bundled/ko-names') {
                Send-File $stream $BundledKoPath 'application/json; charset=utf-8'
            } elseif ($path -eq '/api/select-extract') {
                Handle-SelectExtract $stream
            } elseif ($path -eq '/api/extract-selected') {
                if (-not $script:PendingGameDir) {
                    Send-Json $stream @{ok=$false; error='선택된 게임 폴더가 없습니다. 다시 게임 폴더 선택부터 진행해 주세요.'} 400 'Bad Request'
                } else {
                    Handle-Extract $stream $script:PendingGameDir
                }
            } elseif ($path -eq '/api/extract') {
                Handle-Extract $stream
            } elseif ($path -eq '/api/generated/items') {
                Send-File $stream $script:LastItemsPath 'application/json; charset=utf-8'
            } elseif ($path -eq '/api/generated/recipes') {
                Send-File $stream $script:LastRecipesPath 'application/json; charset=utf-8'
            } elseif ($path -eq '/api/generate-ko-names') {
                Handle-KoreanNames $stream
            } elseif ($path -eq '/api/generated/item-names-ko') {
                Send-File $stream $script:LastKoNamesPath 'application/json; charset=utf-8'
            } else {
                Send-Json $stream @{ok=$false; error='Not found'} 404 'Not Found'
            }
        } catch {
            try { Send-Json $stream @{ok=$false; error=$_.Exception.Message} 500 'Internal Server Error' } catch {}
        } finally {
            try { $client.Close() } catch {}
        }
    }
} finally {
    try { $listener.Stop() } catch {}
    if ($browserSession -and $browserSession.Dedicated) {
        Stop-CalculatorBrowser $browserSession.ProcessName $browserSession.ProfileDir
    }
}
