<?php

namespace Acms\Services\Storage;

use Acms\Services\Storage\Contracts\Filesystem as FilesystemInterface;
use Acms\Services\Storage\Contracts\Base;
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;
    }

    /*
     * 指定されたファイル名がディレクトリトラバーサルを含まないか検証し、絶対パスを返します。
     *
     * @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);

        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) {
            if ($this->isDirectory($file)) {
                $this->removeDirectory($file->getRealPath());
            } else {
                $this->remove($file->getRealPath());
            }
        }
        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
     * @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 {
            if ($zip->extractTo($destination) === false) {
                throw new RuntimeException(sprintf('Failed to extract ZIP archive to: %s', $destination));
            }
        } finally {
            $zip->close();
        }
    }

    /**
     * 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);
    }
}
