<?php
declare(strict_types=1);

namespace FlysystemOffload\StreamWrapper;

use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToReadFile;
use League\Flysystem\UnableToWriteFile;

class FlysystemStreamWrapper
{
    /**
     * @var array<string, FilesystemOperator>
     */
    private static array $filesystems = [];

    /**
     * @var array<string, string>
     */
    private static array $prefixes = [];

    /** @var resource|null */
    private $stream = null;
    private string $protocol = '';
    private string $path = '';
    private string $mode = '';
    private string $flyPath = '';
    private array $dirEntries = [];
    private int $dirPosition = 0;

    /** @var resource|null */
    public $context = null;

    public static function register(FilesystemOperator $filesystem, string $protocol, string $prefix): void
    {
        if (in_array($protocol, stream_get_wrappers(), true)) {
            stream_wrapper_unregister($protocol);
        }

        self::$filesystems[$protocol] = $filesystem;
        self::$prefixes[$protocol]    = trim($prefix, '/');

        stream_wrapper_register($protocol, static::class, STREAM_IS_URL);
    }

    public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
    {
        $protocol      = $this->extractProtocol($path);
        $this->protocol = $protocol;
        $this->path     = $path;
        $this->mode     = $mode;
        $this->flyPath  = $this->resolveFlyPath($path, $protocol);

        $filesystem = $this->filesystem($protocol);

        if (strpbrk($mode, 'waxc')) {
            $this->stream = fopen('php://temp', str_contains($mode, 'b') ? 'w+b' : 'w+');

            if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
                try {
                    $contents = $filesystem->readStream($this->flyPath);
                    if (is_resource($contents)) {
                        stream_copy_to_stream($contents, $this->stream);
                        fclose($contents);
                    }
                } catch (UnableToReadFile $e) {
                    return false;
                }
            }

            rewind($this->stream);

            return true;
        }

        try {
            $resource = $filesystem->readStream($this->flyPath);
            if (! is_resource($resource)) {
                return false;
            }

            $this->stream = $resource;

            return true;
        } catch (UnableToReadFile $e) {
            return false;
        }
    }

    public function stream_read(int $count): string|false
    {
        return fread($this->stream, $count);
    }

    public function stream_write(string $data): int|false
    {
        return fwrite($this->stream, $data);
    }

    public function stream_flush(): bool
    {
        if (! strpbrk($this->mode, 'waxc')) {
            return true;
        }

        $filesystem = $this->filesystem($this->protocol);

        try {
            rewind($this->stream);
            $filesystem->writeStream($this->flyPath, $this->stream);
            rewind($this->stream);

            return true;
        } catch (UnableToWriteFile $e) {
            error_log('[Flysystem Offload] Unable to flush stream: ' . $e->getMessage());

            return false;
        }
    }

    public function stream_close(): void
    {
        $this->stream_flush();

        if (is_resource($this->stream)) {
            fclose($this->stream);
        }

        $this->stream = null;
    }

    public function stream_stat(): array|false
    {
        return $this->url_stat($this->path, 0);
    }

    public function url_stat(string $path, int $flags): array|false
    {
        $protocol  = $this->extractProtocol($path);
        $filesystem = $this->filesystem($protocol);
        $flyPath    = $this->resolveFlyPath($path, $protocol);

        try {
            if (! $filesystem->fileExists($flyPath) && ! $filesystem->directoryExists($flyPath)) {
                if ($flags & STREAM_URL_STAT_QUIET) {
                    return false;
                }

                trigger_error("File or directory not found: {$path}", E_USER_WARNING);

                return false;
            }

            $isDir = $filesystem->directoryExists($flyPath);
            $size  = $isDir ? 0 : $filesystem->fileSize($flyPath);
            $mtime = $filesystem->lastModified($flyPath);

            return [
                0         => 0,
                'dev'     => 0,
                1         => 0,
                'ino'     => 0,
                2         => $isDir ? 0040777 : 0100777,
                'mode'    => $isDir ? 0040777 : 0100777,
                3         => 0,
                'nlink'   => 0,
                4         => 0,
                'uid'     => 0,
                5         => 0,
                'gid'     => 0,
                6         => 0,
                'rdev'    => 0,
                7         => $size,
                'size'    => $size,
                8         => $mtime,
                'atime'   => $mtime,
                9         => $mtime,
                'mtime'   => $mtime,
                10        => $mtime,
                'ctime'   => $mtime,
                11        => -1,
                'blksize' => -1,
                12        => -1,
                'blocks'  => -1,
            ];
        } catch (FilesystemException $e) {
            if (! ($flags & STREAM_URL_STAT_QUIET)) {
                trigger_error($e->getMessage(), E_USER_WARNING);
            }

            return false;
        }
    }

    public function unlink(string $path): bool
    {
        $protocol  = $this->extractProtocol($path);
        $filesystem = $this->filesystem($protocol);
        $flyPath    = $this->resolveFlyPath($path, $protocol);

        try {
            $filesystem->delete($flyPath);

            return true;
        } catch (UnableToDeleteFile $e) {
            return false;
        }
    }

    public function mkdir(string $path, int $mode, int $options): bool
    {
        $protocol  = $this->extractProtocol($path);
        $filesystem = $this->filesystem($protocol);
        $flyPath    = $this->resolveFlyPath($path, $protocol);

        try {
            $filesystem->createDirectory($flyPath);

            return true;
        } catch (FilesystemException $e) {
            return false;
        }
    }

    public function rmdir(string $path, int $options): bool
    {
        $protocol  = $this->extractProtocol($path);
        $filesystem = $this->filesystem($protocol);
        $flyPath    = $this->resolveFlyPath($path, $protocol);

        try {
            $filesystem->deleteDirectory($flyPath);

            return true;
        } catch (FilesystemException $e) {
            return false;
        }
    }

    public function dir_opendir(string $path, int $options): bool
    {
        $protocol        = $this->extractProtocol($path);
        $this->protocol  = $protocol;
        $this->flyPath   = $this->resolveFlyPath($path, $protocol);
        $filesystem      = $this->filesystem($protocol);
        $this->dirEntries = [];

        foreach ($filesystem->listContents($this->flyPath, false) as $item) {
            if ($item instanceof StorageAttributes) {
                $this->dirEntries[] = basename($item->path());
            } elseif (is_array($item) && isset($item['path'])) {
                $this->dirEntries[] = basename($item['path']);
            }
        }

        $this->dirPosition = 0;

        return true;
    }

    public function dir_readdir(): string|false
    {
        if ($this->dirPosition >= count($this->dirEntries)) {
            return false;
        }

        return $this->dirEntries[$this->dirPosition++];
    }

    public function dir_rewinddir(): bool
    {
        $this->dirPosition = 0;

        return true;
    }

    public function dir_closedir(): bool
    {
        $this->dirEntries  = [];
        $this->dirPosition = 0;

        return true;
    }

    public function rename(string $oldPath, string $newPath): bool
    {
        $oldProtocol  = $this->extractProtocol($oldPath);
        $newProtocol  = $this->extractProtocol($newPath);

        if ($oldProtocol !== $newProtocol) {
            return false;
        }

        $filesystem = $this->filesystem($oldProtocol);
        $from       = $this->resolveFlyPath($oldPath, $oldProtocol);
        $to         = $this->resolveFlyPath($newPath, $newProtocol);

        try {
            $filesystem->move($from, $to);

            return true;
        } catch (FilesystemException $e) {
            return false;
        }
    }

    private function filesystem(string $protocol): FilesystemOperator
    {
        if (! isset(self::$filesystems[$protocol])) {
            throw new \RuntimeException('No filesystem registered for protocol: ' . $protocol);
        }

        return self::$filesystems[$protocol];
    }

    private function resolveFlyPath(string $path, ?string $protocol = null): string
    {
        $protocol ??= $this->extractProtocol($path);
        $prefix      = self::$prefixes[$protocol] ?? '';
        $raw         = preg_replace('#^[^:]+://#', '', $path) ?? '';

        $combined = $prefix !== '' ? $prefix . '/' . ltrim($raw, '/') : ltrim($raw, '/');

        return ltrim($combined, '/');
    }

    private function extractProtocol(string $path): string
    {
        $pos = strpos($path, '://');

        return $pos === false ? $this->protocol : substr($path, 0, $pos);
    }
}
