<?php

namespace Acms\Services\Storage;

use Acms\Services\Storage\Contracts\Filesystem as FilesystemInterface;
use Acms\Services\Storage\Contracts\Base;
use Acms\Services\Storage\Exceptions\InsufficientDiskSpaceException;
use Acms\Services\Storage\Exceptions\ZipExtractionException;
use Acms\Services\Facades\Logger;
use Symfony\Component\Filesystem\Path;
use DirectoryIterator;
use RuntimeException;

class Filesystem extends Base implements FilesystemInterface
{
    /**
     * realpath の安全なラッパー関数
     *
     * @param string $path
     * @return string|false
     */
    public function safeRealpath(string $path)
    {
        if (strpos($path, "\0") !== false) {
            Logger::notice('realpath に null バイトが含まれています', ['path' => $path]);
            return false;
        }
        try {
            return realpath($path);
        } catch (\ValueError $e) {
            Logger::notice('realpath に失敗', [
                'path' => $path,
                'error' => $e->getMessage(),
            ]);
            return false;
        }
    }

    /**
     * ファイルの存在確認
     *
     * @param string $path
     * @return bool
     */
    public function exists(string $path): bool
    {
        $path = $this->convertStrToLocal($path);
        if ($path === '') {
            return false;
        }
        $resolved = $this->safeRealpath($path);
        if ($resolved === false) {
            return false;
        }
        return file_exists($resolved);
    }

    /**
     * 指定したパスがファイルかどうかを判定
     *
     * @param string $path
     * @return bool
     */
    public function isFile(string $path): bool
    {
        $path = $this->convertStrToLocal($path);
        if ($path === '') {
            return false;
        }
        $resolved = $this->safeRealpath($path);
        if ($resolved === false) {
            return false;
        }
        return is_file($resolved);
    }

    /**
     * 指定したパスがディレクトリかどうかを判定
     *
     * @param string $path
     * @return bool
     */
    public function isDirectory(string $path): bool
    {
        $path = $this->convertStrToLocal($path);
        if ($path === '') {
            return false;
        }
        $resolved = $this->safeRealpath($path);
        if ($resolved === false) {
            return false;
        }
        return is_dir($resolved);
    }

    /**
     * 実行可能ファイルかどうか
     *
     * @param string $path
     * @return bool
     */
    public function isExecutable(string $path): bool
    {
        $path = $this->convertStrToLocal($path);

        return is_executable($path);
    }

    /**
     * 書き込み可能かどうか
     *
     * @param string $path
     * @return bool
     */
    public function isWritable(string $path): bool
    {
        $path = $this->convertStrToLocal($path);

        return is_writable($path);
    }

    /**
     * 読み込み可能かどうか
     *
     * @param string $path
     * @return bool
     */
    public function isReadable(string $path): bool
    {
        $path = $this->convertStrToLocal($path);

        return is_readable($path);
    }

    /**
     * ファイルがシンボリックリンクかどうか
     *
     * @param string $path
     * @return bool
     */
    public function isLink(string $path): bool
    {
        $path = $this->convertStrToLocal($path);

        return is_link($path);
    }

    /**
     * ファイルのパーミッションを変更する
     *
     * @param string $path
     * @param int|null $mode
     * @return bool
     */
    public function changeMod(string $path, ?int $mode = null): bool
    {
        $path = $this->convertStrToLocal($path);

        if (is_null($mode)) {
            if ($this->isDirectory($path)) {
                $mode = intval($this->directoryMod);
            } else {
                $mode = intval($this->fileMod);
            }
        }
        if ($this->exists($path)) {
            return chmod($path, $mode);
        }
        return false;
    }

    /**
     * カレントディレクトリを変更する
     *
     * @param string $path
     * @return bool
     */
    public function changeDir(string $path): bool
    {
        $path = $this->convertStrToLocal($path);
        if ($this->exists($path)) {
            return chdir($path);
        }
        return false;
    }

    /**
     * ファイルサイズを取得する
     *
     * @param string $path
     * @return int
     */
    public function getFileSize(string $path): int
    {
        $filesize =  filesize($path);
        if (empty($filesize)) {
            return 0;
        }
        return $filesize;
    }

    /**
     * 画像サイズを取得する
     *
     * @param string $path
     * @param array $info
     * @return array{
     *  0: int,
     *  1: int,
     *  2: int,
     *  3: string,
     *  bits: int,
     *  channels: int,
     *  mime: string
     * }|false
     */
    public function getImageSize(string $path, array &$info = [])
    {
        static $cache = [];
        $cacheKey = md5($path);
        $cacheItem = $cache[$cacheKey] ?? null;
        if ($cacheItem !== null) {
            return $cacheItem;
        }
        if ($this->exists($path) && $this->isFile($path)) {
            $imageSize = getimagesize($path);
            $cache[$cacheKey] = $imageSize;

            return $imageSize;
        } elseif (preg_match('/^https?:\/\//', $path)) {
            $headers = get_headers($path);
            if (isset($headers[0]) && strpos($headers[0], '200 OK') !== false) {
                $imageSize = getimagesize($path);
                $cache[$cacheKey] = $imageSize;

                return $imageSize;
            }
        }
        return false;
    }

    /**
     * ファイルのMIMEタイプを取得
     *
     * @param string $path
     * @return string|null
     */
    public function getMimeType(string $path): ?string
    {
        if ($finfo = finfo_open(FILEINFO_MIME_TYPE)) {
            $mimeType = finfo_file($finfo, $path);
            return $mimeType ? $mimeType : null;
        }
        return null;
    }

    /**
     * 基準ディレクトリへ連結してよい相対パスかどうかを判定する
     *
     * アーカイブ領域のファイルパス（DB に保持し ARCHIVES_DIR と連結する）や、テーマ配下の
     * テンプレート名（`include/module/template/...` へ連結する）のように、「利用者入力を
     * 基準ディレクトリへ連結する」経路で連結前に通す。連結結果が基準ディレクトリの外へ
     * 出ないことを保証する。実ファイルの存在も拡張子も問わない。
     *
     * Why not: validateDirectoryTraversalPath() で代替しないのは、あちらが実体（realpath）
     * 起点で公開領域内かを見る判定で、DB へ保存する直前の値のようにまだファイルが存在しない
     * （あるいは既に削除済みの）パスを扱えないため。realpath は存在しないパスに false を返し、
     * 「安全な新規パス」と「危険な遡上パス」を区別できない。
     *
     * @param string $path 基準ディレクトリからの相対パス。空文字は「指定なし」を表す
     *                     正規の値だが、連結対象としては不正なので false を返す
     * @return bool
     */
    public function isSafeRelativePath(string $path): bool
    {
        if ($path === '') {
            return false;
        }
        if (strpos($path, "\0") !== false) {
            return false;
        }
        // Windows 由来の区切り文字を含む値も同一の規則で判定するため「/」へ寄せる
        $normalized = str_replace('\\', '/', $path);

        if (strpos($normalized, '://') !== false) {
            return false;
        }
        if (substr($normalized, 0, 1) === '/') {
            return false;
        }
        if (preg_match('@^[A-Za-z]:@', $normalized) === 1) {
            return false;
        }
        // Why not: strpos による「..」の部分一致で弾かないのは、「..hidden.jpg」のように
        // 二重ドットで始まるだけの正当な名前まで拒否してしまうため。
        foreach (explode('/', $normalized) as $segment) {
            if ($segment === '..') {
                return false;
            }
        }

        return true;
    }

    /*
     * 指定されたファイル名がディレクトリトラバーサルを含まないか検証し、絶対パスを返します。
     *
     * @param string $baseDir
     * @param string $fileName
     * @return string
     */
    public function validateDirectoryTraversal(string $baseDir, string $fileName): string
    {
        $fileName = $this->mbBasename($fileName);
        $realBaseDir = $this->safeRealpath($baseDir);

        if ($realBaseDir === false) {
            Logger::notice('ディレクトリトラバーサルの検証に失敗しました。ベースディレクトリが存在しません。', [
                'baseDir' => $baseDir,
            ]);
            throw new \RuntimeException('ベースディレクトリが存在しません。');
        }

        $realPath = $this->safeRealpath($realBaseDir . DIRECTORY_SEPARATOR . $fileName);

        // realpathに失敗した、またはベースディレクトリ外ならエラー
        if ($realPath === false || strpos($realPath, $realBaseDir) !== 0) {
            Logger::notice('不正なパスです。ディレクトリトラバーサルの可能性があります。', [
                'baseDir' => $baseDir,
                'fileName' => $fileName,
                'realBaseDir' => $realBaseDir,
                'realPath' => $realPath,
            ]);
            throw new \RuntimeException('不正なパスです。');
        }

        return $realPath;
    }

    /**
     * ディレクトリ・トラバーサル対応のため、パスが公開領域のものか確認する
     *
     * @param string $path
     * @param string $publicDir
     * @param bool $checkExists
     * @return bool
     */
    public function validateDirectoryTraversalPath($path, $publicDir = '', $checkExists = true)
    {
        if (!is_string($path)) {
            return false;
        }
        if (!is_string($publicDir)) {
            return false;
        }
        if ($publicDir === '') {
            // cms設置ディレクトリ以下
            $publicDir1 = dirname(SCRIPT_FILE);
            $publicDir2 = dirname($this->safeRealpath(SCRIPT_FILE));
        } else {
            // 指定されたディレクトリ以下
            $publicDir1 = Path::makeAbsolute($publicDir, SCRIPT_DIR);
            $publicDir2 = $this->safeRealpath($publicDir);
        }

        $absolutePath = Path::makeAbsolute($path, SCRIPT_DIR);
        $fileName = $this->mbBasename($path);

        if ($absolutePath === false) {
            return false;
        }
        if (empty($publicDir1) || empty($publicDir2)) {
            return false;
        }
        $secretFileNames = array_merge(configArray('secret_file_name'), ['config.server.php', '.env', '.htaccess']);
        $secretFileNames = array_values(array_unique($secretFileNames));
        if (in_array($fileName, $secretFileNames, true)) {
            return false;
        }
        if ($checkExists) {
            if ($this->exists($absolutePath) === false) {
                return false;
            }
            if ($this->isFile($absolutePath) === false) {
                return false;
            }
        }
        if (strpos($absolutePath, $publicDir1) !== 0 && strpos($absolutePath, $publicDir2) !== 0) {
            return false;
        }
        return true;
    }

    /**
     * ファイルを取得する
     *
     * @param string $path 取得したいファイルパス
     * @param string $publicDir 設定されたディレクトリ以下に取得できるファイルを制限（index.phpからの相対パス可）
     * @return string|false
     * @throws RuntimeException
     */
    public function get(string $path, string $publicDir = '')
    {
        $path = $this->convertStrToLocal($path);

        if ($this->isFile($path) && $this->validateDirectoryTraversalPath($path, $publicDir)) {
            return @file_get_contents($path);
        }
        throw new RuntimeException("File does not exist at path {$path}");
    }

    /**
     * ファイルをストリームで読み込む
     *
     * @param string $path
     * @return resource|false
     */
    public function readStream(string $path)
    {
        return @fopen($path, 'rb');
    }

    /**
     * ファイルを削除する
     *
     * @param string $path
     * @return bool
     */
    public function remove(string $path): bool
    {
        $path = $this->convertStrToLocal($path);

        // symlink は先に判定する。ディレクトリを指す symlink は isFile() が false になるため、
        // symlink チェックを先にしないと unlink されず取りこぼす。symlink 自体を unlink() すれば
        // リンク先の実体には触れない（removeDirectory() にディレクトリ symlink を渡すと
        // RecursiveDirectoryIterator がリンクを追跡し実体側の中身を再帰的に削除してしまうため、
        // symlink デプロイ構成でこの分岐が無いと意図しない実体破壊を招く。CMS-7828）。
        if ($this->isLink($path)) {
            return @unlink($path);
        }

        if ($this->exists($path) && $this->isFile($path)) {
            return @unlink($path);
        }

        return false;
    }

    /**
     * ファイルを保存する
     *
     * @param string $path
     * @param string $content
     * @return int
     */
    public function put(string $path, string $content): int
    {
        $path = $this->convertStrToLocal($path);
        $byte = file_put_contents($path, $content);
        if (is_int($byte)) {
            @$this->changeMod($path);
            return $byte;
        }
        throw new \RuntimeException('failed to put contents in ' . $path);
    }

    /**
     * ファイルをコピーする
     *
     * @param string $from
     * @param string $to
     * @return bool
     */
    public function copy(string $from, string $to): bool
    {
        $to = $this->convertStrToLocal($to);
        $from = $this->convertStrToLocal($from);
        $res = @copy($from, $to);
        $this->changeMod($to);

        if ($this->isFile($from . '.webp')) {
            @copy($from . '.webp', $to . '.webp');
            $this->changeMod($to . '.webp');
        }
        return $res;
    }

    /**
     * ファイルを移動する
     *
     * @param string $from
     * @param string $to
     * @return bool
     */
    public function move(string $from, string $to): bool
    {
        $to = $this->convertStrToLocal($to);
        $from = $this->convertStrToLocal($from);

        $res = @rename($from, $to);
        $this->changeMod($to);

        return $res;
    }

    /**
     * ディレクトリを削除する
     *
     * @param string $dir
     * @return bool
     */
    public function removeDirectory(string $dir): bool
    {
        if (!$this->isDirectory($dir)) {
            return false;
        }

        $it = new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS);
        $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
        foreach ($files as $file) {
            // symlink（ネストした位置に出現した場合を含む）は isLink() で先に判定し、常に
            // remove() でリンク自体だけを外す。getRealPath() は symlink の解決先（実体）を
            // 返すため、isDirectory($file) 判定後に getRealPath() でディレクトリ symlink を
            // removeDirectory() へ渡すと、リンク先の実体を再帰的に破壊してしまい、しかも
            // symlink 自体は取り除かれずダングリングリンクとして残る（CMS-7828）。壊れた
            // symlink は getRealPath() が false を返すため、パス取得には getPathname() を使う。
            $pathname = $file->getPathname();
            if ($this->isLink($pathname)) {
                $this->remove($pathname);
            } elseif ($this->isDirectory($pathname)) {
                $this->removeDirectory($pathname);
            } else {
                $this->remove($pathname);
            }
        }
        rmdir($dir);

        return true;
    }

    /**
     * ディレクトリをコピーする
     *
     * @param string $from
     * @param string $to
     * @return bool
     */
    public function copyDirectory(string $from, string $to): bool
    {
        if (!$this->isDirectory($from)) {
            return false;
        }

        $to = $this->convertStrToLocal($to);
        $from = $this->convertStrToLocal($from);
        $this->makeDirectory($to);
        $dir = opendir($from);
        if ($dir === false) {
            return false;
        }
        while (false !== ($file = readdir($dir))) {
            if ($file !== '.' && $file !== '..') {
                if ($this->isDirectory($from . '/' . $file)) {
                    $this->copyDirectory($from . '/' . $file, $to . '/' . $file);
                } else {
                    $this->copy($from . '/' . $file, $to . '/' . $file);
                    $this->changeMod($to . '/' . $file);
                }
            }
        }
        closedir($dir);
        return true;
    }

    /**
     * ディレクトリを作成する
     *
     * @param string $path
     * @return bool
     */
    public function makeDirectory(string $path): bool
    {
        $dir = '';
        $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); // Windows環境対策でディレクトリ区切り文字を”/”に統一
        foreach (preg_split("@(/)@", $path, -1, PREG_SPLIT_DELIM_CAPTURE) as $i => $token) {
            $dir .= $token;
            if (empty($dir)) {
                continue;
            }
            if ('/' === $token) {
                continue;
            }
            if (!$this->isDirectory($dir)) {
                mkdir($this->convertStrToLocal($dir));
                $this->changeMod($dir);
            }
        }
        return true;
    }

    /**
     * ファイルの更新日時を取得する
     *
     * @param string $path
     * @return int Unix time stamp
     */
    public function lastModified(string $path): int
    {
        $path = $this->convertStrToLocal($path);
        if ($this->exists($path)) {
            return filemtime($path);
        }

        return 0;
    }

    /**
     * ディレクトリ内のファイル一覧を取得する
     *
     * @param string $path
     * @return string[]
     */
    public function getFileList(string $path): array
    {
        $iterator = new DirectoryIterator($path);
        $list = [];
        foreach ($iterator as $item) {
            if ($item->isDot() || $item->isDir()) {
                continue;
            }
            $list[] = $item->getPathname();
        }
        return $list;
    }

    /**
     * ディレクトリ内のサブディレクトリ一覧を取得する（`getFileList` のディレクトリ版）。
     *
     * @param string $path
     * @return string[]
     */
    public function getDirectoryList(string $path): array
    {
        if (!$this->isDirectory($path)) {
            return [];
        }
        $iterator = new DirectoryIterator($path);
        $list = [];
        foreach ($iterator as $item) {
            if ($item->isDot() || !$item->isDir()) {
                continue;
            }
            $list[] = $item->getPathname();
        }
        return $list;
    }

    /**
     * ブログ・年月を考慮したパスを取得
     *
     * @return string
     */
    public function archivesDir(): string
    {
        return sprintf('%03d', BID) . '/' . date('Ym') . '/';
    }

    /**
     * 圧縮する
     *
     * @param string $source
     * @param string $destination
     * @param string $root
     * @param array $exclude
     * @return void
     */
    public function compress(string $source, string $destination, string $root = '', array $exclude = []): void
    {
        $source = $this->convertStrToLocal($source);
        $destination = $this->convertStrToLocal($destination);
        $root = $this->convertStrToLocal($root);

        if (!$this->exists($source)) {
            throw new RuntimeException(sprintf('Source path does not exist: %s', $source));
        }

        $zip = new \ZipArchive();

        // 既存ファイルがあれば追記、なければ新規作成
        // ZipArchive::CREATEのみを使用することで、既存ファイルへの追記が可能
        $mode = \ZipArchive::CREATE;

        $result = $zip->open($destination, $mode);
        if ($result !== true) {
            throw new RuntimeException(sprintf('Failed to open ZIP archive: %s (error code: %d)', $destination, $result));
        }

        try {
            // ZIP内のルートディレクトリ名を決定
            $zipRoot = $root !== '' ? $root : $this->mbBasename($destination, '.zip');

            // ソースがディレクトリの場合
            if ($this->isDirectory($source)) {
                $sourcePath = $this->safeRealpath($source);
                if ($sourcePath === false) {
                    throw new RuntimeException(sprintf('Failed to resolve source path: %s', $source));
                }

                $iterator = new \RecursiveIteratorIterator(
                    new \RecursiveDirectoryIterator($sourcePath, \RecursiveDirectoryIterator::SKIP_DOTS),
                    \RecursiveIteratorIterator::SELF_FIRST
                );

                foreach ($iterator as $item) {
                    $filePath = $item->getRealPath();
                    if ($filePath === false) {
                        continue;
                    }

                    // 除外パスのチェック（前方一致のみ）
                    $relativePath = str_replace($sourcePath . DIRECTORY_SEPARATOR, '', $filePath);
                    $normalizedRelativePath = str_replace('\\', '/', $relativePath);
                    // PHPStan: str_replaceの戻り値が配列型と推論されないように明示的に文字列型を保証
                    assert(is_string($normalizedRelativePath));
                    $shouldExclude = false;
                    foreach ($exclude as $excludePath) {
                        if (!is_string($excludePath)) {
                            continue;
                        }
                        $normalizedExcludePath = str_replace('\\', '/', $excludePath);
                        // 前方一致でチェック（部分一致を避ける）
                        if (strpos($normalizedRelativePath, $normalizedExcludePath) === 0) {
                            $shouldExclude = true;
                            break;
                        }
                    }
                    if ($shouldExclude) {
                        continue;
                    }

                    // ZIP内のパスを構築
                    $zipPath = $zipRoot . '/' . str_replace($sourcePath . DIRECTORY_SEPARATOR, '', $filePath);
                    $zipPath = str_replace('\\', '/', $zipPath);

                    if ($item->isDir()) {
                        // ディレクトリの場合は空のエントリを追加
                        if ($zipPath !== $zipRoot . '/') {
                            $zip->addEmptyDir($zipPath);
                        }
                    } else {
                        // ファイルを追加
                        if ($zip->addFile($filePath, $zipPath) === false) {
                            throw new RuntimeException(sprintf('Failed to add file to ZIP: %s', $filePath));
                        }
                    }
                }
            } else {
                // ソースがファイルの場合
                $filePath = $this->safeRealpath($source);
                if ($filePath === false) {
                    throw new RuntimeException(sprintf('Failed to resolve source file: %s', $source));
                }

                // 除外パスのチェック（前方一致）
                $shouldExclude = false;
                $normalizedFilePath = str_replace('\\', '/', $filePath);
                foreach ($exclude as $excludePath) {
                    if (!is_string($excludePath)) {
                        continue;
                    }
                    $normalizedExcludePath = str_replace('\\', '/', $excludePath);
                    if (strpos($normalizedFilePath, $normalizedExcludePath) === 0) {
                        $shouldExclude = true;
                        break;
                    }
                }
                if (!$shouldExclude) {
                    $zipPath = $zipRoot . '/' . $this->mbBasename($filePath);
                    if ($zip->addFile($filePath, $zipPath) === false) {
                        throw new RuntimeException(sprintf('Failed to add file to ZIP: %s', $filePath));
                    }
                }
            }
        } catch (\Exception $e) {
            // エラー発生時はZIPファイルをクローズしてから例外を再スロー
            $zip->close();
            throw $e;
        }

        // ZIPファイルをクローズ
        if ($zip->close() === false) {
            throw new RuntimeException(sprintf('Failed to close ZIP archive: %s', $destination));
        }
    }

    /**
     * 解凍する
     *
     * @param string $source
     * @param string $destination
     * @throws RuntimeException
     * @throws InsufficientDiskSpaceException
     * @throws ZipExtractionException
     * @return void
     */
    public function unzip(string $source, string $destination): void
    {
        $source = $this->convertStrToLocal($source);
        $destination = $this->convertStrToLocal($destination);

        if (!$this->exists($source)) {
            throw new RuntimeException(sprintf('ZIP file does not exist: %s', $source));
        }

        if (!$this->isReadable($source)) {
            throw new RuntimeException(sprintf('ZIP file is not readable: %s', $source));
        }

        // 解凍先ディレクトリが存在しない場合は作成
        if (!$this->isDirectory($destination)) {
            if (!$this->makeDirectory($destination)) {
                throw new RuntimeException(sprintf('Failed to create destination directory: %s', $destination));
            }
        }

        $zip = new \ZipArchive();
        $result = $zip->open($source);
        if ($result !== true) {
            throw new RuntimeException(sprintf('Failed to open ZIP archive: %s (error code: %d)', $source, $result));
        }

        try {
            $this->assertNoZipSlipEntries($zip);

            $requiredBytes = $this->calculateExtractedSize($zip);
            $freeBytes = $this->getFreeDiskSpace($destination);
            if ($freeBytes !== null && $freeBytes < $requiredBytes) {
                throw new InsufficientDiskSpaceException(sprintf(
                    gettext('ZIP アーカイブの展開に必要な空き容量がありません: %s (必要容量: %s, 空き容量: %s, 展開先: %s)'),
                    $source,
                    $this->formatByteSize($requiredBytes),
                    $this->formatByteSize($freeBytes),
                    $destination
                ));
            }

            // Why not getStatusString(): extractTo() の失敗理由は ZipArchive のステータスには
            // 記録されず（失敗しても "No error" のまま）、PHP の警告としてのみ出力される。
            // 容量不足・権限不足・quota 超過を切り分けるにはその警告文が必要なため、
            // 警告を抑制した上で error_get_last() から取り出して例外メッセージに含める。
            error_clear_last();
            if (@$zip->extractTo($destination) === false) {
                throw new ZipExtractionException(sprintf(
                    gettext('ZIP アーカイブの展開に失敗しました: %s (理由: %s, 必要容量: %s, 空き容量: %s)'),
                    $destination,
                    $this->getLastErrorMessage(),
                    $this->formatByteSize($requiredBytes),
                    $freeBytes === null ? gettext('空き容量不明') : $this->formatByteSize($freeBytes)
                ));
            }
        } finally {
            $zip->close();
        }
    }

    /**
     * ZIP を展開したときに必要になる容量を求める
     *
     * @param \ZipArchive $zip
     * @return float
     */
    private function calculateExtractedSize(\ZipArchive $zip): float
    {
        $size = 0.0;
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $stat = $zip->statIndex($i);
            if ($stat === false) {
                continue;
            }
            $size += (float)$stat['size'];
        }
        return $size;
    }

    /**
     * 指定したパスが属するディスクの空き容量を取得する
     *
     * 取得できない場合（open_basedir 制限下など）は null を返す。
     *
     * @param string $directory
     * @return float|null
     */
    protected function getFreeDiskSpace(string $directory): ?float
    {
        $free = @disk_free_space($directory);
        if ($free === false) {
            return null;
        }
        return $free;
    }

    /**
     * 直前に発生した PHP エラーのメッセージを取得する
     *
     * @return string
     */
    private function getLastErrorMessage(): string
    {
        $error = error_get_last();
        if ($error === null || $error['message'] === '') {
            return gettext('不明なエラー');
        }
        return $error['message'];
    }

    /**
     * バイト数を単位付きの文字列に整形する
     *
     * Why not byteConvert(): あちらは ACMS/function.php のグローバル関数で、
     * この Service を単体テストする際には読み込まれていないため使えない。
     *
     * @param float $bytes
     * @return string
     */
    private function formatByteSize(float $bytes): string
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $index = 0;
        while ($bytes >= 1024 && $index < count($units) - 1) {
            $bytes /= 1024;
            $index++;
        }
        return sprintf('%.1f %s', $bytes, $units[$index]);
    }

    /**
     * ZIP内のエントリ名が展開先ディレクトリの外を指していないか検証する（Zip Slip対策）。
     *
     * ZipArchive::extractTo() 自身のパス正規化はライブラリ・実行環境（libzipのバージョン等）に
     * 依存し挙動が保証されないため、エントリ名を明示的に検証してから展開する。
     *
     * @param \ZipArchive $zip
     * @return void
     * @throws RuntimeException 不正なエントリ名を検知した場合
     */
    private function assertNoZipSlipEntries(\ZipArchive $zip): void
    {
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if ($name === false) {
                continue;
            }

            if ($this->isUnsafeZipEntryName($name)) {
                Logger::notice('不正なZIPエントリを検知しました。展開先ディレクトリの外を指しています。', [
                    'entryName' => $name,
                ]);
                throw new RuntimeException(sprintf('不正なZIPエントリを含んでいます: %s', $name));
            }
        }
    }

    /**
     * ZIPエントリ名が展開先ディレクトリの外を指す不正な名前かどうかを判定する（Zip Slip対策）。
     *
     * 絶対パス（Unix系の "/" 始まり、Windowsのドライブレター）や、パスセグメントに
     * 親ディレクトリ参照（".."）を含む場合を不正と判定する。区切り文字はバックスラッシュも
     * "/" に正規化した上で判定する（Windows環境やアーカイブ作成ツールの差異を吸収するため）。
     *
     * @param string $name ZIP内のエントリ名
     * @return bool
     */
    public function isUnsafeZipEntryName(string $name): bool
    {
        $normalized = str_replace('\\', '/', $name);
        $isAbsolute = $normalized !== '' && ($normalized[0] === '/' || preg_match('#^[A-Za-z]:#', $normalized) === 1);
        $hasParentReference = in_array('..', explode('/', $normalized), true);

        return $isAbsolute || $hasParentReference;
    }

    /**
     * uniqueFilePath() が採番を諦めるまでの最大試行回数
     *
     * createExclusively() が「既に存在する（衝突）」以外の理由（保存先ディレクトリが
     * 無い等）で失敗し続けた場合、番号を変えても解決しないため無限ループになりうる。
     * その保険として上限を設ける。
     */
    private const MAX_UNIQUE_FILE_PATH_ATTEMPTS = 10000;

    /**
     * ユニークなファイルパスを排他的に確保する
     *
     * 候補パスへの排他的な作成（createExclusively）に成功するまで採番をやり直すため、
     * 複数プロセスが同時に呼び出しても同じパスを取り合うことがない。確保できたパスには
     * 空ファイルが作成されるため、呼び出し側は続けてそのパスへ実際の内容を書き込むこと。
     *
     * @param string $original
     * @param string $prefix
     * @param int $num
     * @return string
     * @throws \RuntimeException 試行回数の上限に達した場合
     */
    public function uniqueFilePath(string $original, string $prefix = '', int $num = 0): string
    {
        for ($i = $num; $i < self::MAX_UNIQUE_FILE_PATH_ATTEMPTS; $i++) {
            $path = $this->buildUniqueFilePathCandidate($original, $i);
            if ($this->createExclusively("{$prefix}{$path}")) {
                return $path;
            }
        }
        throw new \RuntimeException(sprintf(
            'ユニークなファイルパスの確保に失敗しました（%d回試行）: %s',
            self::MAX_UNIQUE_FILE_PATH_ATTEMPTS,
            "{$prefix}{$original}"
        ));
    }

    /**
     * ユニークなファイルパスを排他的に確保した上でコールバックを実行する
     *
     * uniqueFilePath() で確保したパスへの書き込みをコールバックに任せ、コールバックが
     * 例外を投げた場合は確保したパス（空ファイル）を削除してから例外を再送出する。
     * これにより、呼び出し側で個別に try/catch を書かなくても「予約したが使われなかった
     * 空ファイルが残る」事態を防げる。
     *
     * @template T
     * @param string $original
     * @param string $prefix
     * @param callable(string $path): T $callback
     * @return T
     */
    public function withUniqueFilePath(string $original, string $prefix, callable $callback)
    {
        $path = $this->uniqueFilePath($original, $prefix);
        try {
            return $callback($path);
        } catch (\Throwable $e) {
            $this->remove("{$prefix}{$path}");
            throw $e;
        }
    }

    /**
     * uniqueFilePath() の候補パスを組み立てる
     *
     * @param string $original
     * @param int $num
     * @return string
     */
    private function buildUniqueFilePathCandidate(string $original, int $num): string
    {
        if ($num === 0) {
            return $original;
        }
        $name = pathinfo($original, PATHINFO_FILENAME);
        $extension = pathinfo($original, PATHINFO_EXTENSION);
        $dir = trim(dirname($original), '/') . '/';
        $path = "{$dir}{$name}_{$num}";
        if ($extension) {
            $path .= ".{$extension}";
        }
        return $path;
    }

    /**
     * 指定したパスにファイルが存在しない場合に限り、空ファイルを排他的に作成する
     *
     * fopen(..., 'x') が失敗する理由は「既に存在する（衝突）」だけでなく、保存先
     * ディレクトリが無い・権限が無い等の構造的な問題でも起こりうる。後者は採番を
     * やり直しても解決しないため、区別せず false を返すと uniqueFilePath() が
     * 無限に採番をやり直しメモリを食い潰す（実際に発生した障害）。作成失敗後に
     * 対象パスが実在するかを確認し、実在しなければ構造的な失敗とみなして例外を
     * 投げる。この確認は fopen 自体の結果を検査するだけなので新たな競合は生まない
     * （実在すれば必ず先に何らかのプロセスが排他作成に成功した後であり、
     * その場合のみ衝突として呼び出し元に採番のやり直しを促す）。
     *
     * @param string $path
     * @return bool 作成できた場合は true、既に存在する場合（＝先に他プロセスが確保した場合）は false
     * @throws \RuntimeException 存在しないにもかかわらず作成に失敗した場合（保存先ディレクトリが無い等）
     */
    protected function createExclusively(string $path): bool
    {
        $path = $this->convertStrToLocal($path);
        $fp = @fopen($path, 'x');
        if ($fp === false) {
            if ($this->exists($path)) {
                return false;
            }
            throw new \RuntimeException("ファイルの作成に失敗しました: {$path}");
        }
        fclose($fp);
        $this->changeMod($path);
        return true;
    }

    /**
     * ファイル名から不正な文字を削除
     *
     * @param string $source
     * @return string
     */
    public function removeIllegalCharacters(string $source): string
    {
        return preg_replace('/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/', '', $source);
    }
}
