<?php

namespace App\Services\VFS;

use CodeIgniter\Files\File;
use Exception;
use ZipArchive;
use PharData;

class LocalAdapter implements IFileSystem
{
    private string $rootPath;

    public function __construct(string $rootPath)
    {
        // Normalize WSL paths on Windows
        if (DIRECTORY_SEPARATOR === '\\' && preg_match('|^/mnt/([a-z])/(.*)|i', $rootPath, $matches)) {
            $rootPath = strtoupper($matches[1]) . ':/' . $matches[2];
        }

        $this->rootPath = rtrim(realpath($rootPath), DIRECTORY_SEPARATOR);
        if (!$this->rootPath || !is_dir($this->rootPath)) {
            throw new Exception("Invalid root path: $rootPath");
        }
    }

    public function resolvePath(string $path): string
    {
        // Normalize slashes
        $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
        // Prevent traversing above root by stripping ..
        $parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), function ($p) {
            return $p !== '' && $p !== '.' && $p !== '..';
        });

        $candidate = $this->rootPath . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);

        $this->assertWithinRoot($candidate);

        $real = realpath($candidate);
        if ($real !== false) {
            return $real;
        }

        return $candidate;
    }

    private function assertWithinRoot(string $candidate): void
    {
        $root = rtrim($this->rootPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;

        $check = $candidate;
        while (!file_exists($check)) {
            $parent = dirname($check);
            if ($parent === $check) {
                break;
            }
            $check = $parent;
        }

        $resolved = realpath($check);
        if ($resolved === false) {
            throw new Exception("Invalid path");
        }

        $resolved = rtrim($resolved, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
        if (!str_starts_with($resolved, $root)) {
            throw new Exception("Path traversal blocked");
        }
    }

    private function isWithinRoot(string $fullPath): bool
    {
        return str_starts_with($fullPath, $this->rootPath);
    }

    public function listDirectory(string $path, bool $showHidden = true): array
    {
        $fullPath = $this->resolvePath($path);
        if (!is_dir($fullPath)) {
            throw new Exception("Directory not found: $path");
        }

        $items = scandir($fullPath);
        $result = [];

        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }

            if (!$showHidden && str_starts_with($item, '.')) {
                continue;
            }

            $itemPath = $fullPath . DIRECTORY_SEPARATOR . $item;
            $relativePath = $path === '/' || $path === '' ? $item : $path . '/' . $item;
            
            $result[] = $this->getMetadataInternal($itemPath, $relativePath);
        }

        return $result;
    }

    public function readFile(string $path): string
    {
        $fullPath = $this->resolvePath($path);
        if (!is_file($fullPath)) {
            throw new Exception("File not found: $path");
        }
        return file_get_contents($fullPath);
    }

    public function writeFile(string $path, string $content): bool
    {
        $fullPath = $this->resolvePath($path);
        return file_put_contents($fullPath, $content) !== false;
    }

    public function delete(string $path): bool
    {
        $fullPath = $this->resolvePath($path);
        if (is_dir($fullPath)) {
            return $this->deleteDirectory($fullPath);
        } elseif (is_file($fullPath)) {
            return unlink($fullPath);
        }
        return false;
    }

    private function deleteDirectory(string $dir): bool
    {
        if (!is_dir($dir)) {
            return false;
        }
        $items = scandir($dir);
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;
            $path = $dir . DIRECTORY_SEPARATOR . $item;
            if (is_link($path) || is_file($path)) {
                unlink($path);
            } elseif (is_dir($path)) {
                $this->deleteDirectory($path);
            }
        }
        return rmdir($dir);
    }

    public function createDirectory(string $path): bool
    {
        $fullPath = $this->resolvePath($path);
        if (file_exists($fullPath)) {
            return false;
        }
        return mkdir($fullPath, 0755, true);
    }

    public function rename(string $from, string $to): bool
    {
        $fullFrom = $this->resolvePath($from);
        $fullTo = $this->resolvePath($to);
        return rename($fullFrom, $fullTo);
    }

    public function move(string $from, string $to): bool
    {
        return $this->rename($from, $to);
    }

    public function copy(string $from, string $to): bool
    {
        $fullFrom = $this->resolvePath($from);
        $fullTo = $this->resolvePath($to);

        if (is_dir($fullFrom)) {
            $this->assertValidCopyTarget($fullFrom, $fullTo);
            return $this->recurseCopy($fullFrom, $fullTo);
        } else {
            return copy($fullFrom, $fullTo);
        }
    }

    private function assertValidCopyTarget(string $src, string $dst): void
    {
        $source = $this->normalizePathForComparison($src);
        $target = $this->normalizePathForComparison($dst);

        if ($source === $target) {
            throw new Exception('Cannot copy a directory onto itself.');
        }

        if (str_starts_with($target, $source . '/')) {
            throw new Exception('Cannot copy a directory into one of its descendants.');
        }
    }

    private function normalizePathForComparison(string $path): string
    {
        $normalized = rtrim(str_replace('\\', '/', $path), '/');

        if (DIRECTORY_SEPARATOR === '\\') {
            $normalized = strtolower($normalized);
        }

        return $normalized;
    }

    private function recurseCopy(string $src, string $dst): bool 
    { 
        $dir = opendir($src); 
        @mkdir($dst); 
        while(false !== ( $file = readdir($dir)) ) { 
            if (( $file != '.' ) && ( $file != '..' )) { 
                $srcPath = $src . '/' . $file;
                $dstPath = $dst . '/' . $file;

                if (is_link($srcPath)) {
                    throw new Exception('Refusing to copy symbolic links.');
                }

                if (is_dir($srcPath)) { 
                    $this->recurseCopy($srcPath, $dstPath); 
                } 
                else { 
                    copy($srcPath, $dstPath); 
                } 
            } 
        } 
        closedir($dir);
        return true; 
    }

    public function archive(array $sources, string $destination): bool
    {
        $fullDest = $this->resolvePath($destination);
        $ext = strtolower(pathinfo($fullDest, PATHINFO_EXTENSION));
        
        if (str_ends_with(strtolower($fullDest), '.tar.gz')) {
            $ext = 'tar.gz';
        }

        if ($ext === 'zip') {
            $zip = new ZipArchive();
            if ($zip->open($fullDest, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
                throw new Exception("Cannot create zip file: $destination");
            }
            foreach ($sources as $source) {
                $fullSource = $this->resolvePath($source);
                $baseName = basename($fullSource);
                if (is_dir($fullSource)) $this->addDirToZip($zip, $fullSource, $baseName);
                else $zip->addFile($fullSource, $baseName);
            }
            return $zip->close();
        } else if ($ext === 'tar' || $ext === 'tar.gz') {
            if (file_exists($fullDest)) unlink($fullDest);
            $archiveName = $ext === 'tar.gz' ? str_replace('.tar.gz', '.tar', $fullDest) : $fullDest;
            $phar = new PharData($archiveName);
            foreach ($sources as $source) {
                $fullSource = $this->resolvePath($source);
                if (is_dir($fullSource)) $phar->buildFromDirectory($fullSource);
                else $phar->addFile($fullSource, basename($fullSource));
            }
            if ($ext === 'tar.gz') {
                $phar->compress(\Phar::GZ);
                unlink($archiveName);
            }
            return true;
        }
        throw new Exception("Unsupported archive format: $ext");
    }

    private function addDirToZip(ZipArchive $zip, string $dir, string $localPath) 
    {
        $zip->addEmptyDir($localPath);
        $files = scandir($dir);
        foreach ($files as $file) {
            if ($file === '.' || $file === '..') continue;
            $fullPath = $dir . DIRECTORY_SEPARATOR . $file;
            $newLocalPath = $localPath . '/' . $file;
            if (is_link($fullPath)) {
                throw new Exception('Refusing to archive symbolic links.');
            }
            if (is_dir($fullPath)) {
                $this->addDirToZip($zip, $fullPath, $newLocalPath);
            } else {
                $zip->addFile($fullPath, $newLocalPath);
            }
        }
    }

    public function extract(string $archive, string $destination): bool
    {
        $fullArchive = $this->resolvePath($archive);
        $fullDest = $this->resolvePath($destination);
        $ext = strtolower(pathinfo($fullArchive, PATHINFO_EXTENSION));

        if (str_ends_with(strtolower($fullArchive), '.tar.gz')) {
            $ext = 'tar.gz';
        }

        if ($ext === 'zip') {
            $zip = new ZipArchive();
            if ($zip->open($fullArchive) === true) {
                for ($i = 0; $i < $zip->numFiles; $i++) {
                    $entryName = (string)$zip->getNameIndex($i);
                    $this->assertSafeArchiveEntryPath($entryName);
                    $this->buildSafeExtractionPath($fullDest, $entryName);
                    if ($this->isZipEntrySymlink($zip, $i)) {
                        throw new Exception('Archive contains symbolic link entries.');
                    }
                }

                $zip->extractTo($fullDest);
                $zip->close();
                return true;
            }
        } else if ($ext === 'tar' || $ext === 'tar.gz') {
            $phar = new PharData($fullArchive);
            return $this->extractTarSafely($phar, $fullDest);
        }

        throw new Exception("Failed to open or unsupported archive: $archive");
    }

    private function extractTarSafely(PharData $phar, string $destination): bool
    {
        $iterator = new \RecursiveIteratorIterator($phar, \RecursiveIteratorIterator::SELF_FIRST);

        foreach ($iterator as $internalPath => $entry) {
            if (!$entry instanceof \PharFileInfo) {
                continue;
            }

            $entryPath = (string)$internalPath;
            $this->assertSafeArchiveEntryPath($entryPath);
            $targetPath = $this->buildSafeExtractionPath($destination, $entryPath);

            if ($entry->isDir()) {
                if (!is_dir($targetPath) && !mkdir($targetPath, 0755, true) && !is_dir($targetPath)) {
                    throw new Exception("Failed to create extraction directory: {$targetPath}");
                }
                continue;
            }

            // Skip links to avoid extracting pointers that can escape root on later operations.
            if ($entry->isLink()) {
                throw new Exception('Archive contains symbolic link entries.');
            }

            $parent = dirname($targetPath);
            if (!is_dir($parent) && !mkdir($parent, 0755, true) && !is_dir($parent)) {
                throw new Exception("Failed to create extraction directory: {$parent}");
            }

            $readStream = $entry->openFile('r');
            $handle = fopen($targetPath, 'wb');
            if ($handle === false) {
                throw new Exception("Failed to write extracted file: {$targetPath}");
            }

            while (!$readStream->eof()) {
                fwrite($handle, (string)$readStream->fread(8192));
            }
            fclose($handle);
        }

        return true;
    }

    private function isZipEntrySymlink(ZipArchive $zip, int $index): bool
    {
        $stat = $zip->statIndex($index);
        if (!is_array($stat)) {
            return false;
        }

        $opsys = (int)($stat['opsys'] ?? 0);
        $externalAttributes = (int)($stat['external_attributes'] ?? 0);

        // Only Unix attributes reliably encode file type bits for symlinks.
        if ($opsys !== ZipArchive::OPSYS_UNIX || $externalAttributes === 0) {
            return false;
        }

        $mode = ($externalAttributes >> 16) & 0xF000;
        return $mode === 0xA000;
    }

    private function assertSafeArchiveEntryPath(string $entryName): void
    {
        $entryName = str_replace('\\', '/', trim($entryName));
        if ($entryName === '') {
            throw new Exception('Archive contains an empty entry path.');
        }

        if ($entryName[0] === '/' || preg_match('/^[a-zA-Z]:\//', $entryName)) {
            throw new Exception('Archive contains an absolute entry path.');
        }

        $parts = explode('/', $entryName);
        foreach ($parts as $part) {
            if ($part === '' || $part === '.') {
                continue;
            }
            if ($part === '..') {
                throw new Exception('Archive contains path traversal entries.');
            }
        }
    }

    private function buildSafeExtractionPath(string $destination, string $entryName): string
    {
        $normalized = trim(str_replace('\\', '/', $entryName), '/');
        $parts = array_values(array_filter(
            explode('/', $normalized),
            static fn(string $part): bool => $part !== '' && $part !== '.'
        ));

        $candidate = rtrim($destination, DIRECTORY_SEPARATOR);
        if (!empty($parts)) {
            $candidate .= DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
        }

        $this->assertWithinDirectory($candidate, $destination);

        return $candidate;
    }

    private function assertWithinDirectory(string $candidate, string $baseDir): void
    {
        $base = rtrim(realpath($baseDir) ?: $baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
        $check = $candidate;

        while (!file_exists($check)) {
            $parent = dirname($check);
            if ($parent === $check) {
                break;
            }
            $check = $parent;
        }

        $resolved = realpath($check);
        if ($resolved === false) {
            throw new Exception('Invalid extraction path.');
        }

        $resolved = rtrim($resolved, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
        if (!str_starts_with($resolved, $base)) {
            throw new Exception('Archive extraction path traversal blocked.');
        }
    }

    public function getMetadata(string $path): ?array
    {
        $fullPath = $this->resolvePath($path);
        if (!file_exists($fullPath)) {
            return null;
        }
        return $this->getMetadataInternal($fullPath, $path);
    }

    public function chmod(string $path, int $mode, bool $recursive = false): bool
    {
        $fullPath = $this->resolvePath($path);
        if (!file_exists($fullPath)) {
            throw new Exception("File not found: $path");
        }
        
        if ($recursive && is_dir($fullPath)) {
            $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($fullPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
            foreach ($iterator as $item) {
                chmod($item->getPathname(), $mode);
            }
        }
        
        return chmod($fullPath, $mode);
    }

    public function chown(string $path, $user, $group, bool $recursive = false): bool
    {
        $fullPath = $this->resolvePath($path);
        if (!file_exists($fullPath)) {
            throw new Exception("File not found: $path");
        }

        $apply = function($p) use ($user, $group) {
            $res = true;
            if ($user) $res = $res && chown($p, $user);
            if ($group) $res = $res && chgrp($p, $group);
            return $res;
        };

        if ($recursive && is_dir($fullPath)) {
            $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($fullPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
            foreach ($iterator as $item) {
                $apply($item->getPathname());
            }
        }

        return $apply($fullPath);
    }

    public function search(string $query): array
    {
        $results = [];
        $dir = new \RecursiveDirectoryIterator($this->rootPath, \RecursiveDirectoryIterator::SKIP_DOTS);
        $iterator = new \RecursiveIteratorIterator($dir, \RecursiveIteratorIterator::SELF_FIRST);

        foreach ($iterator as $file) {
            if (stripos($file->getFilename(), $query) !== false) {
                // Calculate relative path
                $fullPath = $file->getPathname();
                // Ensure it's within root (redundant given Iterator start, but good practice)
                if (str_starts_with($fullPath, $this->rootPath)) {
                    $relativePath = substr($fullPath, strlen($this->rootPath) + 1);
                    // Fix windows slashes
                    $relativePath = str_replace('\\', '/', $relativePath);
                    
                    $results[] = $this->getMetadataInternal($fullPath, $relativePath);
                }
            }
        }
        return $results;
    }

    public function getDirectorySize(string $path): int
    {
        $fullPath = $this->resolvePath($path);
        if (!is_dir($fullPath)) return 0;

        $size = 0;
        foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($fullPath)) as $file) {
            $size += $file->getSize();
        }
        return $size;
    }

    private function getMetadataInternal(string $fullPath, string $relativePath): array
    {
        $isDir = is_dir($fullPath);
        $owner = 'unknown';
        $group = 'unknown';

        if (function_exists('posix_getpwuid')) {
            $ownerData = posix_getpwuid(fileowner($fullPath));
            $owner = $ownerData['name'] ?? $ownerData['uid'];
            $groupData = posix_getgrgid(filegroup($fullPath));
            $group = $groupData['name'] ?? $groupData['gid'];
        }

        $name = basename($fullPath);
        // Ensure valid UTF-8 for JSON compatibility
        if (!mb_check_encoding($name, 'UTF-8')) {
            $name = mb_convert_encoding($name, 'UTF-8', 'ISO-8859-1');
        }

        return [
            'name' => $name,
            'path' => $relativePath,
            'type' => $isDir ? 'dir' : 'file',
            'size' => $isDir ? 0 : filesize($fullPath),
            'mtime' => filemtime($fullPath),
            'perms' => substr(sprintf('%o', fileperms($fullPath)), -4),
            'owner' => $owner,
            'group' => $group,
            'extension' => $isDir ? null : pathinfo($fullPath, PATHINFO_EXTENSION),
            'mime' => $isDir ? 'directory' : mime_content_type($fullPath),
        ];
    }
}
