<?php

namespace Sitecake\Filesystem;

use Sitecake\Exception\UnexistingMethodException;

/**
 * Pure-PHP filesystem abstraction — replaces the League\Flysystem-based wrapper.
 *
 * All paths are relative to the root directory supplied at construction.
 *
 * Inlines all formerly-plugin methods:
 *   ensureDir, listPatternPaths, randomDir, deletePaths, fullPath, copyPaths
 *
 * @package Sitecake\Filesystem
 */
class Filesystem
{
    /** @var string Absolute root path (no trailing slash) */
    protected string $root;

    public function __construct(string $root)
    {
        // Resolve and normalise — fall back to cwd if realpath fails (e.g. non-existing yet)
        $resolved = realpath($root);
        $this->root = $resolved !== false
            ? rtrim(str_replace('\\', '/', $resolved), '/')
            : rtrim(str_replace('\\', '/', $root), '/');
    }

    // ------------------------------------------------------------------
    // Path helpers
    // ------------------------------------------------------------------

    /** Convert a relative path to an absolute one. */
    public function abs(string $path): string
    {
        $path = str_replace('\\', '/', $path);
        if ($path === '' || $path === '.') {
            return $this->root;
        }
        return $this->root . '/' . ltrim($path, '/');
    }

    /**
     * Check that a (possibly non-existent) path is within the filesystem root.
     * Uses realpath() for existing paths; falls back to a string normalization
     * for paths that do not exist yet (creation case).
     * MED-4: Path containment check to prevent directory traversal.
     */
    public function isWithinRoot(string $path): bool
    {
        $abs  = $this->abs($path);
        $root = rtrim(str_replace('\\', '/', realpath($this->root) ?: $this->root), '/');

        // For existing paths, use realpath() to resolve symlinks and ".."
        $real = realpath($abs);
        if ($real !== false) {
            $normalized = rtrim(str_replace('\\', '/', $real), '/');
            return strpos($normalized . '/', $root . '/') === 0;
        }

        // For non-existent paths, normalize manually
        $normalized = $this->normalizePath($abs);
        return strpos($normalized . '/', $root . '/') === 0 || $normalized === $root;
    }

    /**
     * Normalize a path by resolving ".." and "." segments without requiring
     * the path to exist on disk.
     */
    private function normalizePath(string $path): string
    {
        $path  = str_replace('\\', '/', $path);
        $parts = explode('/', $path);
        $stack = [];
        foreach ($parts as $part) {
            if ($part === '' || $part === '.') {
                continue;
            }
            if ($part === '..') {
                array_pop($stack);
            } else {
                $stack[] = $part;
            }
        }
        return (strpos($path, '/') === 0 ? '/' : '') . implode('/', $stack);
    }

    // ------------------------------------------------------------------
    // Basic file operations
    // ------------------------------------------------------------------

    public function has(string $path): bool
    {
        return file_exists($this->abs($path));
    }

    public function isDir(string $path): bool
    {
        return is_dir($this->abs($path));
    }

    public function isFile(string $path): bool
    {
        return is_file($this->abs($path));
    }

    /** Read file contents. Returns false on failure. */
    public function read(string $path)
    {
        $abs = $this->abs($path);
        if (!is_file($abs)) {
            return false;
        }
        return file_get_contents($abs);
    }

    /** Write content (create or overwrite). */
    public function write(string $path, $content): bool
    {
        $abs = $this->abs($path);
        $this->ensureParentDir($abs);
        return file_put_contents($abs, $content) !== false;
    }

    /** Alias for write (Flysystem compatibility). */
    public function put(string $path, $content): bool
    {
        return $this->write($path, $content);
    }

    /** Update existing file content (falls back to write). */
    public function update(string $path, $content): bool
    {
        return $this->write($path, $content);
    }

    /** Write from a stream resource. */
    public function putStream(string $path, $resource): bool
    {
        $abs = $this->abs($path);
        $this->ensureParentDir($abs);
        $dest = fopen($abs, 'wb');
        if ($dest === false) {
            return false;
        }
        stream_copy_to_stream($resource, $dest);
        fclose($dest);
        return true;
    }

    /** Update from a stream resource (alias). */
    public function updateStream(string $path, $resource): bool
    {
        return $this->putStream($path, $resource);
    }

    /** Delete a file. */
    public function delete(string $path): bool
    {
        $abs = $this->abs($path);
        if (is_file($abs)) {
            return unlink($abs);
        }
        return false;
    }

    /** Recursively delete a directory. */
    public function deleteDir(string $path): bool
    {
        $abs = $this->abs($path);
        if (!is_dir($abs)) {
            return false;
        }
        $this->rmdir($abs);
        return true;
    }

    /** Create a directory (recursive). */
    public function createDir(string $path): bool
    {
        $abs = $this->abs($path);
        if (is_dir($abs)) {
            return true;
        }
        return mkdir($abs, 0755, true);
    }

    /** Copy a file. */
    public function copy(string $from, string $to): bool
    {
        $absTo = $this->abs($to);
        $this->ensureParentDir($absTo);
        return copy($this->abs($from), $absTo);
    }

    /** Rename / move a file or directory. */
    public function rename(string $from, string $to): bool
    {
        $absTo = $this->abs($to);
        $this->ensureParentDir($absTo);
        return rename($this->abs($from), $absTo);
    }

    /**
     * Return basic metadata for a path.
     *
     * @return array{timestamp: int, size: int, type: string}
     */
    public function getMetadata(string $path): array
    {
        $abs = $this->abs($path);
        return [
            'timestamp' => (int)filemtime($abs),
            'size'      => is_file($abs) ? (int)filesize($abs) : 0,
            'type'      => is_dir($abs) ? 'dir' : 'file',
        ];
    }

    /**
     * List directory contents.
     *
     * Returns an array of items:
     *   ['path' => string, 'type' => 'file'|'dir', 'basename' => string, 'timestamp' => int, 'size' => int]
     *
     * Paths are relative to the Filesystem root.
     */
    public function listContents(string $directory = '', bool $recursive = false): array
    {
        $absDir = $this->abs($directory);
        if (!is_dir($absDir)) {
            return [];
        }

        $items  = [];
        $handle = opendir($absDir);
        if ($handle === false) {
            return [];
        }

        while (($entry = readdir($handle)) !== false) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            $absEntry  = $absDir . '/' . $entry;
            $relEntry  = ($directory !== '' && $directory !== '.')
                ? ltrim($directory, '/') . '/' . $entry
                : $entry;

            $isDir = is_dir($absEntry);
            $items[] = [
                'path'      => $relEntry,
                'type'      => $isDir ? 'dir' : 'file',
                'basename'  => $entry,
                'timestamp' => (int)filemtime($absEntry),
                'size'      => $isDir ? 0 : (int)filesize($absEntry),
            ];

            if ($recursive && $isDir) {
                foreach ($this->listContents($relEntry, true) as $child) {
                    $items[] = $child;
                }
            }
        }
        closedir($handle);

        return $items;
    }

    /**
     * List contents with specific metadata keys (Flysystem ListWith compatibility).
     * Our implementation always returns all keys; callers can ignore extras.
     */
    public function listWith(array $keys, string $directory = '', bool $recursive = false): array
    {
        return $this->listContents($directory, $recursive);
    }

    // ------------------------------------------------------------------
    // Former plugin methods (inlined)
    // ------------------------------------------------------------------

    /**
     * Ensure a directory exists; create it if needed.
     *
     * @return string|false  The directory path on success, false on failure.
     */
    public function ensureDir(string $directory)
    {
        $abs = $this->abs($directory);
        if (is_dir($abs)) {
            return $directory;
        }
        if (mkdir($abs, 0755, true)) {
            return $directory;
        }
        return false;
    }

    /**
     * List paths in $directory whose basename matches $pattern.
     */
    public function listPatternPaths(string $directory, string $pattern, bool $recursive = false): array
    {
        $items   = $this->listContents($directory, $recursive);
        $matched = [];
        foreach ($items as $item) {
            $normalised = str_replace('\\', '/', $item['path']);
            if (preg_match($pattern, $normalised) === 1) {
                $matched[] = $normalised;
            }
        }
        return $matched;
    }

    /**
     * Return an existing or newly-created random sub-directory of $directory.
     * Directory name pattern: r[0-9a-f]{13}
     *
     * @return string|false
     */
    public function randomDir(string $directory)
    {
        $existing = $this->listPatternPaths($directory, '/^.*\/r[0-9a-f]{13}$/');
        if (!empty($existing)) {
            return $existing[0];
        }
        $dir = $directory . '/' . uniqid('r');
        return $this->ensureDir($dir) !== false ? $dir : false;
    }

    /**
     * Delete a list of file paths.
     */
    public function deletePaths(array $paths): void
    {
        foreach ($paths as $path) {
            $this->delete($path);
        }
    }

    /**
     * Return the absolute filesystem path for a relative path.
     */
    public function fullPath(string $path): string
    {
        return $this->abs($path);
    }

    /**
     * Copy a list of paths from $source prefix to $destination prefix.
     *
     * @param array         $paths
     * @param string        $source       Source path prefix to strip
     * @param string        $destination  Destination path prefix to prepend
     * @param callable|null $callback     Optional callback($from, $to) after each copy
     */
    public function copyPaths(array $paths, string $source, string $destination, callable $callback = null): void
    {
        foreach ($paths as $path) {
            $from = $path;
            $to   = $destination . substr($path, strlen($source));
            $this->copy($from, $to);
            if ($callback !== null) {
                $callback($from, $to);
            }
        }
    }

    // ------------------------------------------------------------------
    // Internal helpers
    // ------------------------------------------------------------------

    protected function ensureParentDir(string $absPath): void
    {
        $dir = dirname($absPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
    }

    protected function rmdir(string $absPath): void
    {
        if (!is_dir($absPath)) {
            return;
        }
        $items = scandir($absPath);
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }
            $sub = $absPath . '/' . $item;
            if (is_dir($sub)) {
                $this->rmdir($sub);
            } else {
                unlink($sub);
            }
        }
        rmdir($absPath);
    }
}
