<?php
/**
 * Validación de rutas del sistema de archivos: múltiples raíces permitidas, compatible
 * con junctions/symlinks y protección frente a directory traversal (vía realpath).
 */

if (!defined('GRID_ROOT')) {
    define('GRID_ROOT', dirname(__DIR__));
}

/**
 * Entorno Windows (incluye CYGWIN/WSL no; solo PHP_OS_FAMILY).
 */
function grid_path_is_windows(): bool {
    return PHP_OS_FAMILY === 'Windows';
}

/**
 * Clave estable para comparar rutas (barra normalizada; Windows sin distinción de mayúsculas).
 */
function grid_path_compare_key(string $path): string {
    $path = str_replace('\\', '/', $path);
    return grid_path_is_windows() ? strtolower($path) : $path;
}

/**
 * Normaliza separadores a / para prefijos (sin cambiar mayúsculas salvo en Windows vía compare).
 */
function grid_path_normalize_slashes(string $path): string {
    return str_replace('\\', '/', $path);
}

/**
 * Comprueba si $path es igual a $root o es un descendiente directo (prefijo estricto con /).
 * En Windows la comparación de prefijo es sin distinción de mayúsculas.
 */
function grid_path_is_same_or_under(string $path, string $root): bool {
    $path = rtrim(grid_path_normalize_slashes($path), '/');
    $root = rtrim(grid_path_normalize_slashes($root), '/');
    if ($path === $root) {
        return true;
    }
    $prefix = $root . '/';
    $lp = strlen($prefix);
    if (strlen($path) < $lp) {
        return false;
    }
    if (grid_path_is_windows()) {
        return strncasecmp($path, $prefix, $lp) === 0;
    }
    return strncmp($path, $prefix, $lp) === 0;
}

/**
 * Letra de unidad en Windows (ej. "C:") o cadena vacía.
 */
function grid_path_windows_drive_prefix(string $path): string {
    if (!grid_path_is_windows()) {
        return '';
    }
    if (preg_match('#^([a-zA-Z]:)#', $path, $m)) {
        return strtoupper($m[1]);
    }
    return '';
}

/**
 * Añade raíces detectadas bajo media/explorer/files: junctions/symlinks cuyo realpath
 * cae fuera del árbol de "files" (típico: otra letra de unidad en XAMPP).
 *
 * En Windows se auto-incluye si el destino está en otra unidad, o si la entrada es un
 * junction/symlink (misma unidad) detectado vía readlink()/is_link().
 * En Linux solo entran destinos de symlinks (is_link) que queden fuera de files.
 *
 * @return list<string>
 */
function grid_media_auto_roots_from_explorer_files(): array {
    $roots = [];
    $filesLogical = GRID_ROOT . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR . 'explorer' . DIRECTORY_SEPARATOR . 'files';
    $filesBase = realpath($filesLogical);
    if ($filesBase === false || !is_dir($filesBase)) {
        return $roots;
    }

    $items = @scandir($filesBase);
    if (!is_array($items)) {
        return $roots;
    }

    $winDriveBase = grid_path_windows_drive_prefix($filesBase);

    foreach ($items as $name) {
        if ($name === '.' || $name === '..' || $name === '') {
            continue;
        }
        if (strpos($name, "\0") !== false) {
            continue;
        }
        $full = $filesBase . DIRECTORY_SEPARATOR . $name;
        if (!grid_path_is_windows() && !is_link($full)) {
            continue;
        }
        $resolved = realpath($full);
        if ($resolved === false || !is_dir($resolved)) {
            continue;
        }
        if (grid_path_is_same_or_under($resolved, $filesBase)) {
            continue;
        }

        if (grid_path_is_windows()) {
            $dRes = grid_path_windows_drive_prefix($resolved);
            $differentDrive = ($winDriveBase !== '' && $dRes !== '' && strcasecmp($dRes, $winDriveBase) !== 0);
            if ($differentDrive || grid_path_is_reparse_or_link($full)) {
                $roots[] = $resolved;
            }
        } else {
            $roots[] = $resolved;
        }
    }

    return $roots;
}

/**
 * Resuelve y deduplica raíces permitidas: siempre incluye realpath(media) si existe,
 * raíces automáticas desde junctions bajo media/explorer/files, más entradas de
 * media_allowed_paths.config.php.
 *
 * @return list<string> Rutas absolutas canónicas (realpath)
 */
function grid_media_resolved_allowed_roots(): array {
    static $cache = null;
    if ($cache !== null) {
        return $cache;
    }

    $raw = [];
    $media = realpath(GRID_ROOT . DIRECTORY_SEPARATOR . 'media');
    if ($media !== false) {
        $raw[] = $media;
    }

    foreach (grid_media_auto_roots_from_explorer_files() as $autoRoot) {
        $raw[] = $autoRoot;
    }

    $configFile = __DIR__ . DIRECTORY_SEPARATOR . 'media_allowed_paths.config.php';
    $extra = [];
    if (is_file($configFile)) {
        $loaded = require $configFile;
        if (is_array($loaded)) {
            $extra = $loaded;
        }
    }

    foreach ($extra as $p) {
        $p = str_replace("\0", '', (string) $p);
        $p = trim($p);
        if ($p === '') {
            continue;
        }
        if (!preg_match('#^([a-zA-Z]:[\\\\/]|/)#', $p)) {
            $p = GRID_ROOT . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, ltrim($p, '/\\'));
        }
        $rp = realpath($p);
        if ($rp !== false) {
            $raw[] = $rp;
        }
    }

    $seen = [];
    $out = [];
    foreach ($raw as $r) {
        $k = grid_path_compare_key($r);
        if (isset($seen[$k])) {
            continue;
        }
        $seen[$k] = true;
        $out[] = $r;
    }

    $cache = $out;
    return grid_media_append_flexia_disk_root($cache);
}

/**
 * Añade flexia disk a las raíces permitidas si existe (respaldo tras formateo).
 *
 * @param list<string> $roots
 * @return list<string>
 */
function grid_media_append_flexia_disk_root(array $roots): array {
    if (!function_exists('grid_flexia_disk_resolved_root')) {
        $flexiaFile = __DIR__ . DIRECTORY_SEPARATOR . 'flexia_disk.php';
        if (is_file($flexiaFile)) {
            require_once $flexiaFile;
        }
    }
    if (!function_exists('grid_flexia_disk_resolved_root')) {
        return $roots;
    }
    $fd = grid_flexia_disk_resolved_root();
    if ($fd === false) {
        return $roots;
    }
    $k = grid_path_compare_key($fd);
    foreach ($roots as $r) {
        if (grid_path_compare_key($r) === $k) {
            return $roots;
        }
    }
    $roots[] = $fd;

    return $roots;
}

/**
 * Indica si la ruta existe y es symlink (Linux/macOS; en Windows los junctions a carpeta
 * a menudo no marcan is_link()).
 */
function grid_path_is_symlink(string $path): bool {
    return is_link(str_replace("\0", '', $path));
}

/**
 * Symlink POSIX, symlink de Windows o junction de directorio (NTFS).
 * En junctions, is_link() a veces es false; readlink() suele exponer el destino.
 */
function grid_path_is_reparse_or_link(string $full): bool {
    $full = str_replace("\0", '', $full);
    if ($full === '' || !file_exists($full)) {
        return false;
    }
    if (is_link($full)) {
        return true;
    }
    if (!grid_path_is_windows() || !is_dir($full)) {
        return false;
    }
    $rl = @readlink($full);

    return $rl !== false && $rl !== '';
}

/**
 * Valida que una ruta canónica esté bajo alguna raíz permitida.
 *
 * @param string|false $realPath Resultado de realpath() o false
 */
function grid_realpath_under_allowed_roots($realPath, ?array $allowedRoots = null): bool {
    if ($realPath === false || $realPath === '') {
        return false;
    }
    $realPath = (string) $realPath;
    if (strpos($realPath, "\0") !== false) {
        return false;
    }
    $roots = $allowedRoots ?? grid_media_resolved_allowed_roots();
    foreach ($roots as $root) {
        if ($root === '' || $root === false) {
            continue;
        }
        if (grid_path_is_same_or_under($realPath, (string) $root)) {
            return true;
        }
    }
    return false;
}

/**
 * Validación central: realpath + whitelist de raíces. Mantiene seguridad frente a
 * ../, rutas mixtas y null bytes; los junctions/symlinks quedan cubiertos al listar
 * el destino real siempre que ese destino esté en la whitelist.
 *
 * @param string $rutaSolicitada Ruta absoluta o relativa (relativa usa el CWD de PHP)
 * @param list<string>|null $allowedRoots Raíces ya resueltas; null = grid_media_resolved_allowed_roots()
 * @return array{ok:bool, real:string|false, error:string}
 */
/**
 * Comprueba si una ruta resuelta por el sistema de archivos está permitida (alias de
 * validarRutaSegura para lectura clara en código nuevo).
 */
function grid_is_path_allowed(string $rutaSolicitada, ?array $allowedRoots = null): bool {
    return validarRutaSegura($rutaSolicitada, $allowedRoots)['ok'];
}

function validarRutaSegura(string $rutaSolicitada, ?array $allowedRoots = null): array {
    $rutaSolicitada = str_replace("\0", '', $rutaSolicitada);
    if ($rutaSolicitada === '') {
        return ['ok' => false, 'real' => false, 'error' => 'Ruta vacía'];
    }

    $roots = $allowedRoots ?? grid_media_resolved_allowed_roots();
    if ($roots === []) {
        return ['ok' => false, 'real' => false, 'error' => 'Sin raíces permitidas configuradas'];
    }

    $real = realpath($rutaSolicitada);
    if ($real === false) {
        return ['ok' => false, 'real' => false, 'error' => 'Ruta inexistente o no resoluble'];
    }

    if (!grid_realpath_under_allowed_roots($real, $roots)) {
        return ['ok' => false, 'real' => $real, 'error' => 'Fuera de directorios autorizados'];
    }

    return ['ok' => true, 'real' => $real, 'error' => ''];
}

/**
 * Comprueba que un archivo o directorio resuelto quede dentro de un directorio padre resuelto
 * (p. ej. listado de carpeta compartida). Ambos deben pasar la whitelist global.
 */
function grid_path_resolved_under_parent(string $candidateReal, string $parentReal): bool {
    $vC = validarRutaSegura($candidateReal, null);
    $vP = validarRutaSegura($parentReal, null);
    if (!$vC['ok'] || !$vP['ok']) {
        return false;
    }
    $c = $vC['real'];
    $p = $vP['real'];
    if ($c === false || $p === false || !is_dir($p)) {
        return false;
    }
    return grid_path_is_same_or_under($c, $p);
}
