#requires -version 3
<#
    rename_tool.ps1
    戦うヒロイン音声素材集 - 神咲エリス・アストレイア
    Fighting Girl Voice Pack - Kanzaki Eris Astraea

    音声ファイルとフォルダの名前を ID 形式（ASCII）に変更します。
    もう一度実行すると元の名前に戻します。
    Renames the voice files and folders to ID form (ASCII).
    Run it again to restore the original names.

    rename_tool.bat から実行してください。
    Launch this through rename_tool.bat.

    表示言語は環境から判定します。固定したいときは
    rename_tool.bat ja / rename_tool.bat en のように指定してください。
    The display language follows your system. To force it, run
    rename_tool.bat ja or rename_tool.bat en .
#>

[CmdletBinding()]
param(
    # 表示言語を固定したいとき: rename_tool.bat ja / rename_tool.bat en
    [ValidateSet('ja', 'en', '')]
    [string]$Lang = ''
)

$ErrorActionPreference = 'Stop'

# ---- 設定 ----------------------------------------------------------

# 版フォルダの名前は書かない。ルート直下から形で見つける。
# （この版に含まれない分類の名前をこのファイルに残さないため）
$EDITION_RE   = '^\d{2}_[A-Za-z0-9]+$'
$LOG_NAME     = '_rename_log.tsv'
$LOG_HEADER   = "TYPE`tOLD`tNEW"

# 大分類フォルダ:  NNN_日本語(English)
$FOLDER_RE  = '^(?<num>\d{3})_.*\((?<en>[^()]+)\)$'
# 変換後の大分類フォルダ:  NNN_English
$FOLDER_ID  = '^\d{3}_[A-Za-z0-9_-]+$'
# ファイル名の ID 部:  N0001 / A0552-B
$ID_RE      = '^[NAF]\d{4}(-[A-Za-z0-9]+)*$'

# ---- 表示 ----------------------------------------------------------

# 引数があればそれに従う。無ければ環境から判定する。
$L = $Lang
if (-not $L) {
    $jaEnv = $false
    try { $jaEnv = ([Console]::OutputEncoding.CodePage -eq 932) } catch { }
    if (-not $jaEnv) { try { $jaEnv = ((Get-Culture).Name -like 'ja*') } catch { } }
    $L = if ($jaEnv) { 'ja' } else { 'en' }
}

# 日本語環境なら日本語だけ、それ以外は英語だけを出す。併記はしない。
function M([string]$ja, [string]$en) { if ($L -eq 'ja') { return $ja } else { return $en } }
function W([string]$ja, [string]$en) { Write-Host (M $ja $en) }
function WB() { Write-Host '' }

# 同じ行を書き換えて進捗を出す。1 件ごとに書くとコンソール描画のほうが
# 重くなるため、STEP 件ごとにまとめて更新する。
$PROGRESS_STEP = 100
$script:ProgWidth = 0

function Show-Progress([string]$label, [int]$done, [int]$total) {
    $line = "  $label $done / $total"
    if ($line.Length -lt $script:ProgWidth) {
        $line = $line.PadRight($script:ProgWidth)
    }
    $script:ProgWidth = $line.Length
    Write-Host ("`r" + $line) -NoNewline
}

function Clear-Progress() {
    if ($script:ProgWidth -gt 0) {
        Write-Host ("`r" + (' ' * $script:ProgWidth) + "`r") -NoNewline
        $script:ProgWidth = 0
    }
}

function Show-Problems($problems) {
    WB
    W '問題が見つかったため、何も変更していません。' `
      'Nothing was changed. The following problems were found:'
    WB
    $problems | Select-Object -First 20 | ForEach-Object { Write-Host "  $_" }
    if ($problems.Count -gt 20) {
        Write-Host ((M '  ... ほか {0} 件' '  ... and {0} more') -f ($problems.Count - 20))
    }
    Write-Host ''
}

function Abort([string]$ja, [string]$en) {
    WB
    Write-Host (M "エラー: $ja" "ERROR: $en")
    WB
    exit 1
}

# ---- 名前の変換規則 --------------------------------------------------

function Convert-FolderName([string]$num, [string]$en) {
    $t = $en -replace "[^A-Za-z0-9 -]", ''
    $t = $t -replace ' +', '_'
    $t = $t.Trim('_')
    if (-not $t) { return $null }
    return "${num}_$t"
}

function Get-IdFromFileName([string]$name) {
    $stem = [System.IO.Path]::GetFileNameWithoutExtension($name)
    $i = $stem.IndexOf('_')
    if ($i -lt 0) { return $stem }      # 既に ID 形式
    return $stem.Substring(0, $i)
}

# ---- 製品ルートの特定 ------------------------------------------------

function Get-EditionDirs([string]$root) {
    $out = New-Object System.Collections.ArrayList
    foreach ($d in (Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue)) {
        if ($d.Name -notmatch $EDITION_RE) { continue }
        foreach ($s in (Get-ChildItem -LiteralPath $d.FullName -Directory -ErrorAction SilentlyContinue)) {
            if ($s.Name -match '^\d{3}_') { [void]$out.Add($d.Name); break }
        }
    }
    return $out
}

function Test-ProductRoot([string]$c) {
    if (Test-Path -LiteralPath (Join-Path $c $LOG_NAME))        { return $true }
    if (Test-Path -LiteralPath (Join-Path $c '_VoiceList.csv'))  { return $true }
    if (Test-Path -LiteralPath (Join-Path $c '_VoiceList.html')) { return $true }
    return ((Get-EditionDirs $c).Count -gt 0)
}

function Find-Root() {
    $here = $PSScriptRoot
    if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
    foreach ($c in @($here, (Split-Path -Parent $here))) {
        if ($c -and (Test-ProductRoot $c)) { return $c }
    }
    return $null
}

$Root = Find-Root
if (-not $Root) {
    Abort '製品フォルダが見つかりません。_Tools ごと製品フォルダの中に置いてください。' `
          'Product folder not found. Keep the _Tools folder inside the product folder.'
}
$Root    = (Resolve-Path -LiteralPath $Root).ProviderPath.TrimEnd('\')
$LogPath = Join-Path $Root $LOG_NAME
$Editions = Get-EditionDirs $Root
if ($Editions.Count -eq 0) {
    Abort '音声フォルダが見つかりません。_Tools ごと製品フォルダの中に置いてください。' `
          'Voice folders not found. Keep the _Tools folder inside the product folder.'
}

function To-Rel([string]$full) {
    if ($full.StartsWith($Root + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
        return $full.Substring($Root.Length + 1)
    }
    return $full
}

# ====================================================================
#  ID 化の計画（ディスクを走査する。変更は一切行わない）
# ====================================================================

function Get-ForwardPlan() {
    $fileOps  = New-Object System.Collections.ArrayList
    $dirOps   = New-Object System.Collections.ArrayList
    $problems = New-Object System.Collections.ArrayList
    $skipped  = 0

    foreach ($ed in $Editions) {
        $edPath = Join-Path $Root $ed
        if (-not (Test-Path -LiteralPath $edPath)) { continue }

        $dirTargets = @{}

        foreach ($cat in (Get-ChildItem -LiteralPath $edPath -Directory)) {

            $m = [regex]::Match($cat.Name, $FOLDER_RE)
            if ($m.Success) {
                $newDirName = Convert-FolderName $m.Groups['num'].Value $m.Groups['en'].Value
                if (-not $newDirName) {
                    [void]$problems.Add("$(M 'フォルダ名を変換できません' 'cannot convert folder name'): $ed\$($cat.Name)")
                    continue
                }
                if ($newDirName -ne $cat.Name) {
                    [void]$dirOps.Add([pscustomobject]@{
                        Old     = $cat.FullName
                        NewName = $newDirName
                    })
                }
            }
            elseif ($cat.Name -match $FOLDER_ID) {
                $newDirName = $cat.Name          # 既に ID 形式
            }
            else {
                [void]$problems.Add("$(M '想定外のフォルダです' 'unexpected folder'): $ed\$($cat.Name)")
                continue
            }

            if ($dirTargets.ContainsKey($newDirName)) {
                [void]$problems.Add("$(M 'フォルダ名が重複します' 'folder name collision'): $ed\$newDirName")
            }
            $dirTargets[$newDirName] = $true

            # --- ファイル ---
            $fileTargets = @{}
            foreach ($f in (Get-ChildItem -LiteralPath $cat.FullName -File -Filter '*.wav')) {
                $id = Get-IdFromFileName $f.Name
                if ($id -notmatch $ID_RE) {
                    [void]$problems.Add("$(M '想定外のファイル名です' 'unexpected file name'): $ed\$($cat.Name)\$($f.Name)")
                    continue
                }
                $newName = "$id.wav"
                if ($fileTargets.ContainsKey($newName)) {
                    [void]$problems.Add("$(M 'ファイル名が重複します' 'file name collision'): $newDirName\$newName")
                }
                $fileTargets[$newName] = $true

                if ($f.Name -eq $newName) { $skipped++; continue }

                if (Test-Path -LiteralPath (Join-Path $cat.FullName $newName)) {
                    [void]$problems.Add("$(M '変更先がすでに存在します' 'target already exists'): $($cat.Name)\$newName")
                    continue
                }
                [void]$fileOps.Add([pscustomobject]@{
                    Old     = $f.FullName
                    NewName = $newName
                })
            }
        }
    }

    return [pscustomobject]@{
        FileOps  = $fileOps
        DirOps   = $dirOps
        Problems = $problems
        Skipped  = $skipped
        Count    = $fileOps.Count + $dirOps.Count
    }
}

# ====================================================================
#  復元の計画（ログを逆順に再生し、ディスク状態を模擬して検証する）
# ====================================================================

function Read-Log() {
    $ops = New-Object System.Collections.ArrayList
    if (-not (Test-Path -LiteralPath $LogPath)) { return $ops }

    $lines = [System.IO.File]::ReadAllLines($LogPath, [System.Text.Encoding]::UTF8)
    foreach ($line in $lines) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        if ($line -eq $LOG_HEADER) { continue }
        $p = $line.Split("`t")
        if ($p.Count -ne 3 -or ($p[0] -ne 'FILE' -and $p[0] -ne 'DIR')) {
            Abort "$LOG_NAME の形式が壊れています。手作業で編集した場合は元に戻してください。" `
                  "$LOG_NAME is malformed. If you edited it by hand, restore the original."
        }
        [void]$ops.Add([pscustomobject]@{ Type = $p[0]; Old = $p[1]; New = $p[2] })
    }
    return $ops
}

# 現在のディスク状態を、大分類フォルダとその中のファイル名として取り込む
function Get-DiskModel() {
    $dirs  = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([StringComparer]::OrdinalIgnoreCase)
    $files = @{}
    foreach ($ed in $Editions) {
        $edPath = Join-Path $Root $ed
        if (-not (Test-Path -LiteralPath $edPath)) { continue }
        foreach ($cat in (Get-ChildItem -LiteralPath $edPath -Directory)) {
            $rel = To-Rel $cat.FullName
            [void]$dirs.Add($rel)
            $set = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([StringComparer]::OrdinalIgnoreCase)
            foreach ($f in (Get-ChildItem -LiteralPath $cat.FullName -File)) { [void]$set.Add($f.Name) }
            $files[$rel] = $set
        }
    }
    return [pscustomobject]@{ Dirs = $dirs; Files = $files }
}

function Get-BackwardPlan([System.Collections.ArrayList]$ops) {
    $problems = New-Object System.Collections.ArrayList
    $plan     = New-Object System.Collections.ArrayList
    if ($ops.Count -eq 0) {
        return [pscustomobject]@{ Plan = $plan; Problems = $problems; Files = 0; Dirs = 0; Count = 0 }
    }

    # 逆順に再生する。ログの各行は記録時点のディスク状態に対応しているため、
    # 逆順に辿れば、その行の New は常にその時点で実在するパスになる。
    $rev = New-Object System.Collections.ArrayList
    for ($i = $ops.Count - 1; $i -ge 0; $i--) { [void]$rev.Add($ops[$i]) }

    $m = Get-DiskModel
    $nFile = 0; $nDir = 0

    foreach ($op in $rev) {
        if ($op.Type -eq 'DIR') {
            $cur = $op.New; $dst = $op.Old
            if (-not $m.Dirs.Contains($cur)) {
                [void]$problems.Add("$(M 'フォルダが見つかりません' 'folder not found'): $cur"); continue
            }
            if ($cur -ne $dst -and $m.Dirs.Contains($dst)) {
                [void]$problems.Add("$(M 'フォルダがすでに存在します' 'folder already exists'): $dst"); continue
            }
            [void]$m.Dirs.Remove($cur)
            [void]$m.Dirs.Add($dst)
            $m.Files[$dst] = $m.Files[$cur]
            $m.Files.Remove($cur)
            $nDir++
        }
        else {
            $curDir  = Split-Path -Parent $op.New
            $curLeaf = Split-Path -Leaf   $op.New
            $dstDir  = Split-Path -Parent $op.Old
            $dstLeaf = Split-Path -Leaf   $op.Old
            if ($curDir -ne $dstDir) {
                [void]$problems.Add("$(M '記録の内容が想定と違います' 'unexpected move in log'): $($op.Old)"); continue
            }
            if (-not $m.Files.ContainsKey($curDir) -or -not $m.Files[$curDir].Contains($curLeaf)) {
                [void]$problems.Add("$(M 'ファイルが見つかりません' 'file not found'): $($op.New)"); continue
            }
            if ($curLeaf -ne $dstLeaf -and $m.Files[$curDir].Contains($dstLeaf)) {
                [void]$problems.Add("$(M 'ファイルがすでに存在します' 'file already exists'): $($op.Old)"); continue
            }
            [void]$m.Files[$curDir].Remove($curLeaf)
            [void]$m.Files[$curDir].Add($dstLeaf)
            $nFile++
        }
        [void]$plan.Add([pscustomobject]@{
            Path    = Join-Path $Root $op.New
            NewName = Split-Path -Leaf $op.Old
        })
    }

    return [pscustomobject]@{
        Plan = $plan; Problems = $problems
        Files = $nFile; Dirs = $nDir; Count = $plan.Count
    }
}

# ====================================================================
#  使用中のファイルを探す
#
#  ブラウザで _VoiceList.html を開いて試聴していると、その音声ファイルの
#  ハンドルが残り、名前の変更に失敗する。ファイルが 1 つでも開かれていると
#  その親フォルダの名前も変更できない。何かを変更する前に見つけて止める。
# ====================================================================

function Get-LockedFiles([int]$limit = 10) {
    $bad = New-Object System.Collections.ArrayList
    $files = New-Object System.Collections.ArrayList
    foreach ($ed in $Editions) {
        foreach ($f in (Get-ChildItem -LiteralPath (Join-Path $Root $ed) `
                        -Filter '*.wav' -File -Recurse -ErrorAction SilentlyContinue)) {
            [void]$files.Add($f.FullName)
        }
    }
    $label = M '使用中のファイルを確認しています' 'Checking for files in use'
    $i = 0
    foreach ($path in $files) {
        $i++
        if ($i % $PROGRESS_STEP -eq 0) { Show-Progress $label $i $files.Count }
        try {
            # 共有を一切許さずに開く。他が掴んでいれば失敗する。
            $fs = [System.IO.File]::Open($path, 'Open', 'Read', 'None')
            $fs.Close()
            $fs.Dispose()
        }
        catch {
            [void]$bad.Add((To-Rel $path))
            if ($bad.Count -ge $limit) { break }
        }
    }
    Clear-Progress
    return $bad
}

function Show-Locked($locked) {
    WB
    W '使用中のファイルがあるため、何も変更していません。' `
      'Nothing was changed. These files are in use by another program:'
    WB
    $locked | ForEach-Object { Write-Host "  $_" }
    WB
    W 'ブラウザで開いている対応表 (_VoiceList.html) や、音声を読み込んでいる' `
      'Close the voice list in your browser, and any DAW, game engine or'
    W 'ソフト (DAW / Unity / エクスプローラのプレビュー) を閉じてから、' `
      'preview pane that has loaded these files, then run this tool'
    W 'もう一度実行してください。' 'again.'
    WB
}

# ====================================================================
#  方向の決定
# ====================================================================

$fwd = Get-ForwardPlan
$log = Read-Log
$bwd = Get-BackwardPlan $log

$dir = ''
if ($fwd.Count -gt 0 -and $bwd.Count -eq 0) { $dir = 'F' }
elseif ($fwd.Count -eq 0 -and $bwd.Count -gt 0) { $dir = 'B' }
elseif ($fwd.Count -eq 0 -and $bwd.Count -eq 0) {
    WB
    if ($fwd.Skipped -gt 0) {
        W 'すべて ID 形式です。変更するものはありません。' `
          'Everything is already in ID form. Nothing to do.'
    } else {
        W '変更するものはありません。' 'Nothing to do.'
    }
    WB
    exit 0
}
else {
    # 両方に作業がある。ID 形式で運用中に、日本語名のファイルが
    # 追加された場合など。どちらを行うか尋ねる。
    WB
    W 'この製品フォルダには、ID 形式のものと元の名前のものが混在しています。' `
      'This product folder contains a mix of ID names and original names.'
    WB
    Write-Host ((M '  1) ID 形式にする       : {0} 件 / フォルダ {1} 件' `
                   '  1) Rename to ID form      : {0} files, {1} folders') -f $fwd.FileOps.Count, $fwd.DirOps.Count)
    Write-Host ((M '  2) 元の名前に戻す      : {0} 件 / フォルダ {1} 件' `
                   '  2) Restore original names : {0} files, {1} folders') -f $bwd.Files, $bwd.Dirs)
    W '  N) 中止' '  N) Cancel'
    WB
    $sel = Read-Host (M '番号を選んでください (1/2/N)' 'Select (1/2/N)')
    if ($sel -eq '1') { $dir = 'F' }
    elseif ($sel -eq '2') { $dir = 'B' }
    else {
        WB
        W '中止しました。変更していません。' 'Cancelled. Nothing was changed.'
        WB
        exit 0
    }
}

$problems = if ($dir -eq 'F') { $fwd.Problems } else { $bwd.Problems }
if ($problems.Count -gt 0) { Show-Problems $problems; exit 1 }

# ====================================================================
#  確認
# ====================================================================

WB
$exJa = '    01_Normal\001_あいさつ(Greeting)\N0001_ごきげんよう.wav'
$exEn = '    01_Normal\001_[japanese](Greeting)\N0001_[japanese].wav'
$exId = '    01_Normal\001_Greeting\N0001.wav'

if ($dir -eq 'F') {
    W 'このツールは、音声ファイルとフォルダの名前を ID 形式に変更します。' `
      'This tool renames the voice files and folders to ID form.'
    WB
    Write-Host (M $exJa $exEn)
    Write-Host '      ->'
    Write-Host $exId
    WB
    Write-Host (M "  対象     : $Root" "  Folder : $Root")
    Write-Host ((M '  ファイル : {0} 件' '  Files  : {0}') -f $fwd.FileOps.Count)
    Write-Host ((M '  フォルダ : {0} 件' '  Folders: {0}') -f $fwd.DirOps.Count)
    if ($fwd.Skipped -gt 0) {
        Write-Host ((M '  （{0} 件はすでに ID 形式のため、そのままにします）' `
                       '  ({0} files are already in ID form and will be left alone)') -f $fwd.Skipped)
    }
    WB
    W "変更内容は $LOG_NAME に記録されます。もう一度このツールを実行すると元に戻ります。" `
      "Changes are recorded in $LOG_NAME . Run this tool again to restore the original names."
} else {
    W 'このツールは、ID 形式に変更した名前を元に戻します。' `
      'This tool restores the original file and folder names.'
    WB
    Write-Host $exId
    Write-Host '      ->'
    Write-Host (M $exJa $exEn)
    WB
    Write-Host (M "  対象     : $Root" "  Folder : $Root")
    Write-Host ((M '  ファイル : {0} 件' '  Files  : {0}') -f $bwd.Files)
    Write-Host ((M '  フォルダ : {0} 件' '  Folders: {0}') -f $bwd.Dirs)
    WB
    W "完了すると $LOG_NAME は削除されます。" `
      "$LOG_NAME will be deleted when this finishes."
}
WB

W '実行する前に、対応表 (_VoiceList.html) を開いているブラウザと、' `
  'Before you continue, close the voice list in your browser and any'
W 'この音声を読み込んでいるソフトを閉じてください。' `
  'program that has these files open.'
WB

$ans = Read-Host (M '実行しますか (Y/N)' 'Proceed? (Y/N)')
if ($ans -ne 'Y' -and $ans -ne 'y') {
    WB
    W '中止しました。変更していません。' 'Cancelled. Nothing was changed.'
    WB
    exit 0
}

# ====================================================================
#  実行
# ====================================================================

function Write-Log($lines) {
    [System.IO.File]::WriteAllLines($LogPath, [string[]]$lines, (New-Object System.Text.UTF8Encoding $true))
}

WB
$locked = Get-LockedFiles
if ($locked.Count -gt 0) { Show-Locked $locked; exit 1 }

if ($dir -eq 'F') {
    # 既存のログには追記する。逆順再生の不変条件は追記でも保たれる。
    $lines = New-Object System.Collections.ArrayList
    [void]$lines.Add($LOG_HEADER)
    foreach ($o in $log) { [void]$lines.Add("$($o.Type)`t$($o.Old)`t$($o.New)") }

    # ファイルを先に、フォルダを後に。
    $ops = New-Object System.Collections.ArrayList
    foreach ($op in $fwd.FileOps) { [void]$ops.Add(@{ Type = 'FILE'; Op = $op }) }
    foreach ($op in $fwd.DirOps)  { [void]$ops.Add(@{ Type = 'DIR';  Op = $op }) }

    $applied = New-Object System.Collections.ArrayList
    $failMsg = $null
    $label = M '名前を変更しています' 'Renaming'
    $i = 0
    foreach ($e in $ops) {
        $i++
        if ($i % $PROGRESS_STEP -eq 0) { Show-Progress $label $i $ops.Count }
        $old = $e.Op.Old
        $new = Join-Path (Split-Path -Parent $old) $e.Op.NewName
        try { Rename-Item -LiteralPath $old -NewName $e.Op.NewName }
        catch { $failMsg = $_.Exception.Message; break }
        [void]$applied.Add(@{ Type = $e.Type; From = $old; To = $new })
    }
    Clear-Progress

    if ($failMsg) {
        # 途中で失敗したら、変更した分を元に戻す。中途半端な状態で終わらせない。
        $stuck = New-Object System.Collections.ArrayList
        for ($i = $applied.Count - 1; $i -ge 0; $i--) {
            $a = $applied[$i]
            try { Rename-Item -LiteralPath $a.To -NewName (Split-Path -Leaf $a.From) }
            catch {
                for ($k = 0; $k -le $i; $k++) { [void]$stuck.Add($applied[$k]) }
                break
            }
        }
        Write-Host ''
        if ($stuck.Count -eq 0) {
            W '処理中に失敗したため、変更した分はすべて元に戻しました。' `
              'FAILED. Everything that had been changed was put back.'
            W '音声を開いているソフトを閉じてから、もう一度実行してください。' `
              'Close any program using these files and run this tool again.'
        }
        else {
            foreach ($a in $stuck) {
                [void]$lines.Add("$($a.Type)`t$(To-Rel $a.From)`t$(To-Rel $a.To)")
            }
            Write-Log $lines
            WB
            Write-Host ((M '処理中に失敗し、{0} 件は元に戻せませんでした。' `
                           'FAILED. {0} item(s) could not be put back.') -f $stuck.Count)
            W "$LOG_NAME に残してあります。もう一度実行すると元に戻せます。" `
              "They are recorded in $LOG_NAME ; run this tool again to restore."
        }
        Write-Host ''
        Write-Host $failMsg
        Write-Host ''
        exit 1
    }

    foreach ($a in $applied) {
        [void]$lines.Add("$($a.Type)`t$(To-Rel $a.From)`t$(To-Rel $a.To)")
    }
    Write-Log $lines
    WB
    Write-Host ((M '完了しました。ファイル {0} 件 / フォルダ {1} 件。' `
                   'Done. {0} files, {1} folders.') -f $fwd.FileOps.Count, $fwd.DirOps.Count)
    W "変更前後の対応は $LOG_NAME に記録されています。" `
      "The old and new names are recorded in $LOG_NAME ."
    WB
}
else {
    $doneCount = 0
    $failMsg = $null
    $label = M '名前を戻しています' 'Restoring'
    foreach ($p in $bwd.Plan) {
        if ($doneCount % $PROGRESS_STEP -eq 0) {
            Show-Progress $label $doneCount $bwd.Plan.Count
        }
        try { Rename-Item -LiteralPath $p.Path -NewName $p.NewName }
        catch { $failMsg = $_.Exception.Message; break }
        $doneCount++
    }
    Clear-Progress

    if ($failMsg) {
        # ログを逆順に処理しているので、末尾 $doneCount 件が復元済み。
        # 残りだけを書き戻せば、もう一度実行して続きから戻せる。
        $keep = $log.Count - $doneCount
        if ($keep -le 0) {
            Remove-Item -LiteralPath $LogPath -Force
        }
        else {
            $lines = New-Object System.Collections.ArrayList
            [void]$lines.Add($LOG_HEADER)
            for ($i = 0; $i -lt $keep; $i++) {
                [void]$lines.Add("$($log[$i].Type)`t$($log[$i].Old)`t$($log[$i].New)")
            }
            Write-Log $lines
        }
        WB
        Write-Host ((M '処理中に失敗しました。{0} 件は元に戻しました。' `
                       'FAILED. {0} item(s) were restored.') -f $doneCount)
        W '音声を開いているソフトを閉じてから、もう一度実行すると続きから戻せます。' `
          'Close any program using these files and run this tool again to continue.'
        Write-Host ''
        Write-Host $failMsg
        Write-Host ''
        exit 1
    }

    Remove-Item -LiteralPath $LogPath -Force
    WB
    Write-Host ((M '元に戻しました。ファイル {0} 件 / フォルダ {1} 件。' `
                   'Restored. {0} files, {1} folders.') -f $bwd.Files, $bwd.Dirs)
    WB
}
