<?php

declare(strict_types=1);

namespace PostService\Node;

final class FileScanner
{
    private array $hostRoots = [];
    private int $maxFileSize;

    public function __construct(
        array $hosts,
        int $maxFileSize
    ) {
        $this->maxFileSize = $maxFileSize;
        foreach ($hosts as $host => $root) {
            $canonicalRoot = realpath((string) $root);
            if ($canonicalRoot === false || !is_dir($canonicalRoot) || !is_readable($canonicalRoot)) {
                throw new \InvalidArgumentException("Configured root for {$host} is not a readable directory");
            }
            $this->hostRoots[strtolower((string) $host)] = rtrim($canonicalRoot, DIRECTORY_SEPARATOR);
        }
    }

    public function scan(array $request, int $maxFiles, int $timeBudgetSeconds): array
    {
        $url = $request['url'] ?? null;
        if (!is_string($url) || strlen($url) > 4096) {
            throw new ApiException('A valid URL is required', 'invalid_url');
        }

        [$path, $route, $urlParts] = $this->resolveUrl($url);
        if (is_file($path)) {
            return $this->scanSingleFile($path, $route, $urlParts);
        }
        if (!is_dir($path)) {
            throw new ApiException('The requested path does not exist', 'path_missing', 404);
        }

        return $this->scanDirectory($path, $route, $urlParts, $request, $maxFiles, $timeBudgetSeconds);
    }

    private function scanDirectory(
        string $path,
        string $route,
        array $urlParts,
        array $request,
        int $maxFiles,
        int $timeBudgetSeconds
    ): array {
        $entries = scandir($path);
        if ($entries === false) {
            throw new ApiException('Directory could not be read', 'directory_unreadable', 422);
        }

        $files = [];
        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $candidate = $path . DIRECTORY_SEPARATOR . $entry;
            if (is_file($candidate) && is_readable($candidate)) {
                $files[] = $entry;
            }
        }
        usort($files, static function (string $left, string $right): int {
            $natural = strnatcasecmp($left, $right);
            return $natural !== 0 ? $natural : strcmp($left, $right);
        });

        $versionContext = hash_init('sha256');
        foreach ($files as $entry) {
            $candidate = $path . DIRECTORY_SEPARATOR . $entry;
            $stat = @stat($candidate);
            if ($stat === false) {
                throw new ApiException('Directory changed while it was being listed', 'directory_changed', 409);
            }
            hash_update($versionContext, $entry . "\0" . $stat['size'] . "\0" . $stat['mtime'] . "\n");
        }
        $directoryVersion = hash_final($versionContext);

        $expectedVersion = $request['directoryVersion'] ?? null;
        if ($expectedVersion !== null && !hash_equals((string) $expectedVersion, $directoryVersion)) {
            throw new ApiException('Directory changed between scan pages', 'directory_changed', 409);
        }

        $offset = filter_var($request['offset'] ?? 0, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
        if ($offset === false || $offset > count($files)) {
            throw new ApiException('Invalid scan offset', 'invalid_offset');
        }

        $startedAt = microtime(true);
        $results = [];
        $index = $offset;
        while ($index < count($files) && count($results) < $maxFiles) {
            if ($results !== [] && microtime(true) - $startedAt >= $timeBudgetSeconds) {
                break;
            }
            $entry = $files[$index];
            $results[] = $this->metadata(
                $path . DIRECTORY_SEPARATOR . $entry,
                $entry,
                $route,
                $urlParts,
                $index
            );
            $index++;
        }

        return [
            'kind' => 'directory',
            'directoryVersion' => $directoryVersion,
            'totalFiles' => count($files),
            'files' => $results,
            'nextOffset' => $index,
            'done' => $index >= count($files),
        ];
    }

    private function scanSingleFile(string $path, string $route, array $urlParts): array
    {
        return [
            'kind' => 'file',
            'directoryVersion' => null,
            'totalFiles' => 1,
            'files' => [$this->metadata($path, basename($path), dirname($route), $urlParts, 0)],
            'nextOffset' => 1,
            'done' => true,
        ];
    }

    private function metadata(
        string $path,
        string $fileName,
        string $route,
        array $urlParts,
        int $position
    ): array {
        $stat = @stat($path);
        if ($stat === false || !is_file($path) || !is_readable($path)) {
            throw new ApiException('File changed while it was being scanned', 'file_changed', 409);
        }

        $size = (int) $stat['size'];
        $fingerprint = FileFingerprint::calculate($path, $size);
        clearstatcache(true, $path);
        $after = @stat($path);
        if ($after === false || (int) $after['size'] !== $size || (int) $after['mtime'] !== (int) $stat['mtime']) {
            throw new ApiException('File changed while it was being hashed', 'file_changed', 409);
        }

        $mimeType = function_exists('mime_content_type') ? @mime_content_type($path) : null;

        return [
            'position' => $position,
            'fileName' => $fileName,
            'url' => $this->buildFileUrl($urlParts, $route, $fileName),
            'fileSize' => $size,
            'fileSizeHuman' => self::formatSize($size),
            'mimeType' => is_string($mimeType) ? $mimeType : 'application/octet-stream',
            'hashVersion' => FileFingerprint::VERSION,
            'fileHash' => $fingerprint,
            'modifiedAt' => date('Y-m-d H:i:s', (int) $stat['mtime']),
            'over2GB' => $size > $this->maxFileSize,
        ];
    }

    private function resolveUrl(string $url): array
    {
        $parts = parse_url($url);
        if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
            throw new ApiException('URL must include a scheme and host', 'invalid_url');
        }
        if (!in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true)) {
            throw new ApiException('Only HTTP and HTTPS URLs are supported', 'invalid_url');
        }

        $host = strtolower((string) $parts['host']);
        if (!isset($this->hostRoots[$host])) {
            throw new ApiException('URL host is not assigned to this node', 'host_not_allowed', 403);
        }

        $route = $this->extractRoute($parts);
        if ($route === '' || strpos($route, "\0") !== false) {
            throw new ApiException('URL does not contain a usable route', 'invalid_route');
        }

        $candidate = $this->hostRoots[$host] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $route);
        $resolved = realpath($candidate);
        if ($resolved === false) {
            throw new ApiException('The requested path does not exist', 'path_missing', 404);
        }

        $root = $this->hostRoots[$host];
        if ($resolved !== $root && strpos($resolved, $root . DIRECTORY_SEPARATOR) !== 0) {
            throw new ApiException('Resolved path is outside the configured root', 'path_not_allowed', 403);
        }

        return [$resolved, $route, $parts];
    }

    private function extractRoute(array $parts): string
    {
        if (isset($parts['query'])) {
            parse_str((string) $parts['query'], $query);
            if (isset($query['dir']) && is_string($query['dir'])) {
                return trim(rawurldecode($query['dir']), '/');
            }
        }
        return trim(rawurldecode((string) ($parts['path'] ?? '')), '/');
    }

    private function buildFileUrl(array $parts, string $route, string $fileName): string
    {
        $encodedParts = array_map('rawurlencode', array_values(array_filter(explode('/', trim($route, '/')), 'strlen')));
        $path = implode('/', $encodedParts);
        $authority = (string) $parts['host'];
        if (isset($parts['port'])) {
            $authority .= ':' . (int) $parts['port'];
        }
        return strtolower((string) $parts['scheme']) . '://' . $authority
            . ($path === '' ? '' : '/' . $path)
            . '/' . rawurlencode($fileName);
    }

    private static function formatSize(int $size): string
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $value = (float) $size;
        $unit = 0;
        while ($value >= 1024 && $unit < count($units) - 1) {
            $value /= 1024;
            $unit++;
        }
        return round($value, 2) . ' ' . $units[$unit];
    }
}
