<?php

declare(strict_types=1);

namespace PhpWA\Storage;

use InvalidArgumentException;
use PhpWA\Exception\StorageException;
use PhpWA\Runtime\SafeRuntime;

/** @internal Shared hardened file operations for stores and account locks. */
final class PrivateFileArea
{
    /** @var string */ private $directory;

    public function __construct(string $directory)
    {
        if ($directory === '' || $directory[0] !== "\x2f" || strpos($directory, "\0") !== false) {
            throw new InvalidArgumentException('Private storage directory must be an absolute path.');
        }
        if (is_link($directory)) {
            throw new StorageException('Private storage directory is unsafe.');
        }
        $created = is_dir($directory) || SafeRuntime::call(static function () use ($directory): bool {
            return mkdir($directory, 0700, true);
        }, false);
        if (!$created && !is_dir($directory)) {
            throw new StorageException('Unable to create the private storage directory.');
        }
        SafeRuntime::call(static function () use ($directory): bool {
            return chmod($directory, 0700);
        }, false);
        clearstatcache(true, $directory);
        if (is_link($directory) || !is_dir($directory)) {
            throw new StorageException('Private storage directory is unsafe.');
        }
        $real = realpath($directory);
        $stat = SafeRuntime::call(static function () use ($directory) {
            return stat($directory);
        }, false);
        if (!is_string($real) || !is_array($stat) || (($stat['mode'] ?? 0) & 0077) !== 0) {
            throw new StorageException('Private storage directory permissions are unsafe.');
        }
        if (function_exists('posix_geteuid') && isset($stat['uid']) && $stat['uid'] !== posix_geteuid()) {
            throw new StorageException('Private storage directory has an unexpected owner.');
        }
        $this->directory = rtrim($real, DIRECTORY_SEPARATOR);
    }

    public function accountPath(string $purpose, string $accountId, string $extension): string
    {
        if (preg_match('/\A[a-z][a-z0-9-]{0,31}\z/D', $purpose) !== 1 || preg_match('/\A\.[a-z0-9]{1,12}\z/D', $extension) !== 1) {
            throw new InvalidArgumentException('Private storage file purpose or extension is invalid.');
        }
        self::assertAccountId($accountId);
        return $this->directory . DIRECTORY_SEPARATOR . $purpose . '-' . hash('sha256', $accountId) . $extension;
    }

    /** @return resource|null */
    public function openLock(string $path, bool $nonBlocking)
    {
        $this->assertChildPath($path);
        $handle = $this->openOrCreatePrivateFile($path);
        $operation = LOCK_EX | ($nonBlocking ? LOCK_NB : 0);
        if (!SafeRuntime::call(static function () use ($handle, $operation): bool {
            return flock($handle, $operation);
        }, false)) {
            fclose($handle);
            return null;
        }
        return $handle;
    }

    public function read(string $path, int $maximumBytes): ?string
    {
        $this->assertChildPath($path);
        clearstatcache(true, $path);
        if (!file_exists($path) && !is_link($path)) {
            return null;
        }
        $handle = $this->openExistingPrivateFile($path, 'rb');
        try {
            $stat = fstat($handle);
            if (!is_array($stat) || ($stat['size'] ?? -1) < 0 || $stat['size'] > $maximumBytes) {
                throw new StorageException('Private storage file exceeds its size limit.');
            }
            $data = '';
            while (!feof($handle)) {
                $remaining = $maximumBytes - strlen($data);
                if ($remaining <= 0) {
                    throw new StorageException('Private storage file exceeds its size limit.');
                }
                $chunk = fread($handle, min(8192, $remaining));
                if (!is_string($chunk)) {
                    throw new StorageException('Unable to read the private storage file.');
                }
                if ($chunk === '' && !feof($handle)) {
                    throw new StorageException('Private storage read made no progress.');
                }
                $data .= $chunk;
            }
            return $data;
        } finally {
            fclose($handle);
        }
    }

    public function writeAtomically(string $path, string $data): void
    {
        $this->assertChildPath($path);
        $temporary = $path . '.tmp-' . bin2hex(random_bytes(16));
        $handle = SafeRuntime::call(static function () use ($temporary) {
            return fopen($temporary, 'x+b');
        }, false);
        if (!is_resource($handle)) {
            throw new StorageException('Unable to create a private temporary storage file.');
        }
        try {
            if (!SafeRuntime::call(static function () use ($temporary): bool {
                return chmod($temporary, 0600);
            }, false)) {
                throw new StorageException('Unable to secure the private temporary storage file.');
            }
            $offset = 0;
            $length = strlen($data);
            while ($offset < $length) {
                $written = fwrite($handle, substr($data, $offset, 8192));
                if (!is_int($written) || $written < 1) {
                    throw new StorageException('Unable to write the private storage file.');
                }
                $offset += $written;
            }
            if (!fflush($handle)) {
                throw new StorageException('Unable to flush the private storage file.');
            }
            if (function_exists('fsync') && !fsync($handle)) {
                throw new StorageException('Unable to synchronise the private storage file.');
            }
            $this->assertOpenPrivateFile($handle, $temporary);
            fclose($handle);
            $handle = null;
            if (!SafeRuntime::call(static function () use ($temporary, $path): bool {
                return rename($temporary, $path);
            }, false)) {
                throw new StorageException('Unable to atomically replace the private storage file.');
            }
            SafeRuntime::call(static function () use ($path): bool {
                return chmod($path, 0600);
            }, false);
            clearstatcache(true, $path);
            $check = $this->openExistingPrivateFile($path, 'rb');
            fclose($check);
        } finally {
            if (is_resource($handle)) {
                fclose($handle);
            }
            if (file_exists($temporary) || is_link($temporary)) {
                SafeRuntime::call(static function () use ($temporary): bool {
                    return unlink($temporary);
                }, false);
            }
        }
    }

    public function delete(string $path): void
    {
        $this->assertChildPath($path);
        clearstatcache(true, $path);
        if (!file_exists($path) && !is_link($path)) {
            return;
        }
        $handle = $this->openExistingPrivateFile($path, 'rb');
        fclose($handle);
        if (!SafeRuntime::call(static function () use ($path): bool {
            return unlink($path);
        }, false)) {
            throw new StorageException('Unable to delete the private storage file.');
        }
    }

    /** @return resource */
    private function openOrCreatePrivateFile(string $path)
    {
        clearstatcache(true, $path);
        if (!file_exists($path) && !is_link($path)) {
            $created = SafeRuntime::call(static function () use ($path) {
                return fopen($path, 'x+b');
            }, false);
            if (is_resource($created)) {
                if (!SafeRuntime::call(static function () use ($path): bool {
                    return chmod($path, 0600);
                }, false)) {
                    fclose($created);
                    SafeRuntime::call(static function () use ($path): bool {
                        return unlink($path);
                    }, false);
                    throw new StorageException('Unable to secure the private lock file.');
                }
                $this->assertOpenPrivateFile($created, $path);
                return $created;
            }
        }
        return $this->openExistingPrivateFile($path, 'r+b');
    }

    /** @return resource */
    private function openExistingPrivateFile(string $path, string $mode)
    {
        $before = SafeRuntime::call(static function () use ($path) {
            return lstat($path);
        }, false);
        if (!is_array($before) || self::isSymbolicLinkMode((int) ($before['mode'] ?? 0))) {
            throw new StorageException('Private storage file is missing or unsafe.');
        }
        $handle = SafeRuntime::call(static function () use ($path, $mode) {
            return fopen($path, $mode);
        }, false);
        if (!is_resource($handle)) {
            throw new StorageException('Unable to open the private storage file.');
        }
        try {
            $this->assertOpenPrivateFile($handle, $path, $before);
            return $handle;
        } catch (\Throwable $error) {
            fclose($handle);
            throw $error;
        }
    }

    /** @param resource $handle @param array<string|int,mixed>|null $expected */
    private function assertOpenPrivateFile($handle, string $path, ?array $expected = null): void
    {
        $opened = fstat($handle);
        $current = SafeRuntime::call(static function () use ($path) {
            return lstat($path);
        }, false);
        if (!is_array($opened) || !is_array($current)) {
            throw new StorageException('Unable to validate the private storage file.');
        }
        if (!self::isRegularFileMode((int) ($opened['mode'] ?? 0)) || ($opened['nlink'] ?? 0) !== 1) {
            throw new StorageException('Private storage file type or link count is unsafe.');
        }
        if ((($opened['mode'] ?? 0) & 0077) !== 0) {
            throw new StorageException('Private storage file permissions are unsafe.');
        }
        if (($opened['dev'] ?? null) !== ($current['dev'] ?? null) || ($opened['ino'] ?? null) !== ($current['ino'] ?? null)) {
            throw new StorageException('Private storage file changed while being opened.');
        }
        if ($expected !== null && (($opened['dev'] ?? null) !== ($expected['dev'] ?? null) || ($opened['ino'] ?? null) !== ($expected['ino'] ?? null))) {
            throw new StorageException('Private storage file changed while being opened.');
        }
        if (function_exists('posix_geteuid') && isset($opened['uid']) && $opened['uid'] !== posix_geteuid()) {
            throw new StorageException('Private storage file has an unexpected owner.');
        }
    }

    private function assertChildPath(string $path): void
    {
        $prefix = $this->directory . DIRECTORY_SEPARATOR;
        if (strncmp($path, $prefix, strlen($prefix)) !== 0 || dirname($path) !== $this->directory || strpos(basename($path), DIRECTORY_SEPARATOR) !== false) {
            throw new InvalidArgumentException('Private storage path is outside its protected directory.');
        }
    }

    private static function assertAccountId(string $accountId): void
    {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1 || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1) {
            throw new InvalidArgumentException('Account ID is invalid.');
        }
    }

    private static function isRegularFileMode(int $mode): bool
    {
        return ($mode & 0170000) === 0100000;
    }

    private static function isSymbolicLinkMode(int $mode): bool
    {
        return ($mode & 0170000) === 0120000;
    }
}
