<?php

declare(strict_types=1);

namespace App\Framework\Filesystem;

use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\SystemTimer;
use App\Framework\DateTime\Timer;
use App\Framework\Filesystem\ValueObjects\FileChangeEvent;
use App\Framework\Filesystem\ValueObjects\FilePath;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * File system watcher for detecting file changes
 */
final class FileWatcher
{
    private array $fileHashes = [];

    private array $fileMtimes = [];

    private bool $running = false;

    private FilePath $basePath;

    private Timer $timer;

    public function __construct(?FilePath $basePath = null, ?Timer $timer = null)
    {
        $this->basePath = $basePath ?? FilePath::fromString(dirname(__DIR__, 3));
        $this->timer = $timer ?? new SystemTimer();
    }

    /**
     * Watch for file changes
     */
    public function watch(
        array $patterns,
        array $ignore,
        callable $callback,
        ?Duration $pollInterval = null
    ): void {
        $this->running = true;
        $pollInterval ??= Duration::fromSeconds(1);

        // Initial scan
        $this->scanFiles($patterns, $ignore);

        // Polling loop
        while ($this->running) {
            $changes = $this->detectChanges($patterns, $ignore);

            foreach ($changes as $change) {
                $callback($change);
            }

            // Sleep for poll interval
            $this->timer->sleep($pollInterval);
        }
    }

    /**
     * Watch for changes once (non-blocking)
     */
    public function watchOnce(array $patterns, array $ignore): array
    {
        return $this->detectChanges($patterns, $ignore);
    }

    /**
     * Stop watching
     */
    public function stop(): void
    {
        $this->running = false;
    }

    /**
     * Scan files and build initial hash map
     */
    private function scanFiles(array $patterns, array $ignore): void
    {
        $files = $this->getMatchingFiles($patterns, $ignore);

        foreach ($files as $filePath) {
            if ($filePath->exists() && $filePath->isFile()) {
                $path = $filePath->toString();
                $this->fileHashes[$path] = md5_file($path);
                $this->fileMtimes[$path] = filemtime($path);
            }
        }
    }

    /**
     * Detect changes in watched files
     * @return FileChangeEvent[]
     */
    private function detectChanges(array $patterns, array $ignore): array
    {
        $changes = [];
        $currentFiles = $this->getMatchingFiles($patterns, $ignore);

        // Check for new and modified files
        foreach ($currentFiles as $filePath) {
            if (! $filePath->exists() || ! $filePath->isFile()) {
                continue;
            }

            $path = $filePath->toString();
            $currentHash = md5_file($path);
            $currentMtime = filemtime($path);

            if (! isset($this->fileHashes[$path])) {
                // New file
                $changes[] = FileChangeEvent::create($path, FileChangeType::CREATED);
                $this->fileHashes[$path] = $currentHash;
                $this->fileMtimes[$path] = $currentMtime;
            } elseif ($this->fileHashes[$path] !== $currentHash || $this->fileMtimes[$path] !== $currentMtime) {
                // Modified file
                $changes[] = FileChangeEvent::create($path, FileChangeType::MODIFIED);
                $this->fileHashes[$path] = $currentHash;
                $this->fileMtimes[$path] = $currentMtime;
            }
        }

        // Check for deleted files
        $currentPaths = array_map(fn (FilePath $fp) => $fp->toString(), $currentFiles);
        foreach ($this->fileHashes as $path => $hash) {
            if (! in_array($path, $currentPaths, true)) {
                $changes[] = FileChangeEvent::create($path, FileChangeType::DELETED);
                unset($this->fileHashes[$path]);
                unset($this->fileMtimes[$path]);
            }
        }

        return $changes;
    }

    /**
     * Get files matching patterns
     * @return FilePath[]
     */
    private function getMatchingFiles(array $patterns, array $ignore): array
    {
        $files = [];

        foreach ($patterns as $pattern) {
            $files = array_merge($files, $this->globRecursive($pattern, $ignore));
        }

        // Remove duplicates based on path string
        $uniquePaths = [];
        foreach ($files as $filePath) {
            $pathStr = $filePath->toString();
            $uniquePaths[$pathStr] = $filePath;
        }

        return array_values($uniquePaths);
    }

    /**
     * Recursive glob with ignore patterns
     * @return FilePath[]
     */
    private function globRecursive(string $pattern, array $ignore): array
    {
        $files = [];
        $pattern = $this->basePath->join($pattern)->toString();

        // Handle ** wildcard for recursive search
        if (str_contains($pattern, '**')) {
            $basePath = substr($pattern, 0, strpos($pattern, '**'));
            $filePattern = substr($pattern, strpos($pattern, '**') + 3);

            $basePathObj = FilePath::fromString($basePath);
            if (! $basePathObj->isDirectory()) {
                return [];
            }

            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($basePath, RecursiveDirectoryIterator::SKIP_DOTS),
                RecursiveIteratorIterator::SELF_FIRST
            );

            foreach ($iterator as $file) {
                $filePath = FilePath::fromString($file->getPathname());

                // Check ignore patterns
                if ($this->shouldIgnore($filePath, $ignore)) {
                    continue;
                }

                // Match file pattern
                if ($this->matchesPattern($filePath, $filePattern)) {
                    $files[] = $filePath;
                }
            }
        } else {
            // Simple glob
            $matches = glob($pattern);
            if ($matches !== false) {
                foreach ($matches as $file) {
                    $filePath = FilePath::fromString($file);
                    if (! $this->shouldIgnore($filePath, $ignore)) {
                        $files[] = $filePath;
                    }
                }
            }
        }

        return $files;
    }

    /**
     * Check if file should be ignored
     */
    private function shouldIgnore(FilePath $file, array $ignorePatterns): bool
    {
        $fileStr = $file->toString();

        foreach ($ignorePatterns as $pattern) {
            $fullPattern = $this->basePath->join($pattern)->toString();

            if (str_contains($fullPattern, '**')) {
                $regex = str_replace('**', '.*', $fullPattern);
                $regex = str_replace('/', '\/', $regex);
                if (preg_match('/^' . $regex . '/', $fileStr)) {
                    return true;
                }
            } elseif (fnmatch($fullPattern, $fileStr)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if file matches pattern
     */
    private function matchesPattern(FilePath $file, string $pattern): bool
    {
        // Convert glob pattern to regex
        $pattern = str_replace('*', '.*', $pattern);
        $pattern = str_replace('/', '\/', $pattern);

        return (bool)preg_match('/' . $pattern . '$/', $file->toString());
    }
}
