<?php

declare(strict_types=1);

namespace PpPhp\Modules\CustomFiles\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\ModuleManager;
use PpPhp\Core\DB;
use PpPhp\Core\Security;
use PpPhp\Core\ProjectNav;

/**
 * Custom Files — a project-scoped file manager.
 *
 * Users create / upload / edit static or dynamic files and folders under
 * projects/<user>/<slug>/custom/. At build time BuildWriter copies this tree
 * into the generated app's root so the files are served as part of the site.
 *
 * SECURITY: every filesystem operation is confined to the project's custom/
 * directory via safe() — traversal ("..") and out-of-base paths are rejected.
 */
class CustomFilesController
{
    private array $project = [];
    private string $slug = '';

    /** Directories/files that must never be edited here (they are system paths in the build). */
    private const RESERVED_TOP = [
        '_runtime.php', 'index.php', 'app-shell.php', 'cron.php', 'routes.json',
        'manifest.json', 'database.sql', 'core', 'app', 'api', 'config', 'generated', 'plugins',
    ];

    public function __construct(
        private Config $config,
        private ModuleManager $moduleManager
    ) {}

    // ── page ────────────────────────────────────────────────────────
    public function index(string $slug): string
    {
        $this->initProject($slug);
        $project   = $this->project;
        $navItems  = ProjectNav::items('/custom-files');
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = $_SESSION['user'];
        $pageTitle = 'Custom Files';

        ob_start();
        include __DIR__ . '/../Views/index.php';
        return ob_get_clean();
    }

    // ── API ─────────────────────────────────────────────────────────
    public function tree(string $slug): void
    {
        $this->apiInit($slug);
        echo json_encode(['success' => true, 'tree' => $this->buildTree($this->base(), '')]);
        exit;
    }

    public function read(string $slug): void
    {
        $this->apiInit($slug);
        $abs = $this->safe((string) ($_GET['path'] ?? ''));
        if ($abs === null || !is_file($abs)) {
            $this->fail('File not found.', 404);
        }
        $size = filesize($abs) ?: 0;
        $ext  = strtolower((string) pathinfo($abs, PATHINFO_EXTENSION));
        $imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'];

        if (in_array($ext, $imageExts, true) && $size <= 768 * 1024) {
            $mime = $ext === 'svg' ? 'image/svg+xml' : ($ext === 'ico' ? 'image/x-icon' : 'image/' . ($ext === 'jpg' ? 'jpeg' : $ext));
            echo json_encode(['success' => true, 'image' => true, 'size' => $size, 'ext' => $ext,
                'dataUri' => 'data:' . $mime . ';base64,' . base64_encode((string) file_get_contents($abs))]);
            exit;
        }
        if ($size > 2 * 1024 * 1024) {
            echo json_encode(['success' => true, 'binary' => true, 'size' => $size, 'ext' => $ext, 'reason' => 'too_large']);
            exit;
        }
        $content = (string) file_get_contents($abs);
        if (strpos($content, "\0") !== false) {
            echo json_encode(['success' => true, 'binary' => true, 'size' => $size, 'ext' => $ext]);
            exit;
        }
        echo json_encode(['success' => true, 'content' => $content, 'size' => $size, 'ext' => $ext]);
        exit;
    }

    public function save(string $slug): void
    {
        $this->apiInit($slug);
        $in = $this->input();
        $abs = $this->safe((string) ($in['path'] ?? ''));
        if ($abs === null) $this->fail('Invalid path.');
        if (is_dir($abs)) $this->fail('That path is a folder.');
        if (!is_dir(dirname($abs))) @mkdir(dirname($abs), 0775, true);
        if (@file_put_contents($abs, (string) ($in['content'] ?? '')) === false) {
            $this->fail('Could not write the file.');
        }
        echo json_encode(['success' => true]);
        exit;
    }

    public function mkdir(string $slug): void
    {
        $this->apiInit($slug);
        $in = $this->input();
        $abs = $this->safe((string) ($in['path'] ?? ''));
        if ($abs === null) $this->fail('Invalid path.');
        if (file_exists($abs)) $this->fail('A file or folder with that name already exists.');
        if (!@mkdir($abs, 0775, true)) $this->fail('Could not create the folder.');
        echo json_encode(['success' => true]);
        exit;
    }

    public function create(string $slug): void
    {
        $this->apiInit($slug);
        $in = $this->input();
        $abs = $this->safe((string) ($in['path'] ?? ''));
        if ($abs === null) $this->fail('Invalid path.');
        if (file_exists($abs)) $this->fail('A file with that name already exists.');
        if (!is_dir(dirname($abs))) @mkdir(dirname($abs), 0775, true);
        if (@file_put_contents($abs, (string) ($in['content'] ?? '')) === false) {
            $this->fail('Could not create the file.');
        }
        echo json_encode(['success' => true]);
        exit;
    }

    public function rename(string $slug): void
    {
        $this->apiInit($slug);
        $in = $this->input();
        $from = $this->safe((string) ($in['from'] ?? ''));
        $to   = $this->safe((string) ($in['to'] ?? ''));
        if ($from === null || $to === null) $this->fail('Invalid path.');
        if (!file_exists($from)) $this->fail('Source no longer exists.', 404);
        if (file_exists($to)) $this->fail('A file or folder with that name already exists.');
        if (!is_dir(dirname($to))) @mkdir(dirname($to), 0775, true);
        if (!@rename($from, $to)) $this->fail('Could not rename.');
        echo json_encode(['success' => true]);
        exit;
    }

    public function delete(string $slug): void
    {
        $this->apiInit($slug);
        $in = $this->input();
        $abs = $this->safe((string) ($in['path'] ?? ''));
        if ($abs === null || $abs === $this->base()) $this->fail('Invalid path.');
        if (!file_exists($abs)) { echo json_encode(['success' => true]); exit; }
        $this->rrmdir($abs);
        echo json_encode(['success' => true]);
        exit;
    }

    public function upload(string $slug): void
    {
        $this->apiInit($slug);
        $target = (string) ($_POST['path'] ?? '');
        $paths  = (array) ($_POST['paths'] ?? []);   // per-file relative path (for folder uploads)
        if (empty($_FILES['files'])) $this->fail('No files uploaded.');

        $files = $_FILES['files'];
        $names = (array) $files['name'];
        $tmps  = (array) $files['tmp_name'];
        $errs  = (array) $files['error'];
        $stored = 0; $skipped = [];

        foreach ($names as $i => $name) {
            if (($errs[$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) continue;
            $rel = trim((string) ($paths[$i] ?? $name));
            $abs = $this->safe(($target !== '' ? rtrim($target, '/') . '/' : '') . $rel);
            if ($abs === null) { $skipped[] = $name; continue; }
            if (!is_dir(dirname($abs))) @mkdir(dirname($abs), 0775, true);
            if (@move_uploaded_file($tmps[$i], $abs) || @copy($tmps[$i], $abs)) $stored++;
            else $skipped[] = $name;
        }
        echo json_encode(['success' => true, 'stored' => $stored, 'skipped' => $skipped]);
        exit;
    }

    /**
     * Serve a custom file read-only for the live preview iframe. The file path
     * is carried in the URL path (not a query) so a previewed page's RELATIVE
     * links to sibling files (css/, js/, images) resolve back through here.
     *
     * SECURITY: auth + project ownership (initProject), path-confined (safe()),
     * and the bytes are only STREAMED — a .php file is sent as source text, never
     * executed. The SPA frames this in a sandboxed iframe (no same-origin), so the
     * previewed content cannot reach the console session.
     */
    public function preview(string $slug, string $path): void
    {
        $this->initProject($slug);
        $abs = $this->safe(rawurldecode($path));
        if ($abs === null || !is_file($abs)) {
            http_response_code(404);
            header('Content-Type: text/plain; charset=utf-8');
            echo 'Not found.';
            exit;
        }
        $ext = strtolower((string) pathinfo($abs, PATHINFO_EXTENSION));
        header('Content-Type: ' . $this->mimeFor($ext));
        header('X-Frame-Options: SAMEORIGIN');            // allow same-origin framing
        header("Content-Security-Policy: frame-ancestors 'self'");
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: no-store, max-age=0');     // always the latest saved bytes
        header('Content-Length: ' . (string) (filesize($abs) ?: 0));
        while (ob_get_level() > 0) ob_end_clean();
        readfile($abs);
        exit;
    }

    /** Stream every custom file as a single ZIP download. */
    public function downloadZip(string $slug): void
    {
        $this->initProject($slug);
        if (!class_exists(\ZipArchive::class)) {
            http_response_code(500);
            header('Content-Type: text/plain; charset=utf-8');
            echo 'ZIP support is not available on this server.';
            exit;
        }
        $base = $this->base();
        $tmp  = tempnam(sys_get_temp_dir(), 'cfzip');
        $zip  = new \ZipArchive();
        if ($zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            @unlink($tmp);
            http_response_code(500);
            header('Content-Type: text/plain; charset=utf-8');
            echo 'Could not create the archive.';
            exit;
        }
        $count = 0;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($it as $file) {
            if (!$file->isFile()) continue;
            $rel = ltrim(str_replace($base, '', $file->getPathname()), '/\\');
            $zip->addFile($file->getPathname(), $rel);
            $count++;
        }
        $zip->close();
        if ($count === 0) {
            @unlink($tmp);
            http_response_code(404);
            header('Content-Type: text/plain; charset=utf-8');
            echo 'There are no custom files to download yet.';
            exit;
        }
        $name = (preg_replace('/[^a-zA-Z0-9_-]/', '-', (string) ($this->project['slug'] ?? 'project')) ?: 'project') . '-custom-files.zip';
        while (ob_get_level() > 0) ob_end_clean();
        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . $name . '"');
        header('Content-Length: ' . (string) (filesize($tmp) ?: 0));
        header('Cache-Control: no-store');
        readfile($tmp);
        @unlink($tmp);
        exit;
    }

    /** Content-type for the preview endpoint. PHP is served as source text (never run). */
    private function mimeFor(string $ext): string
    {
        static $map = [
            'html' => 'text/html; charset=utf-8', 'htm' => 'text/html; charset=utf-8',
            'css' => 'text/css; charset=utf-8', 'js' => 'text/javascript; charset=utf-8',
            'mjs' => 'text/javascript; charset=utf-8', 'json' => 'application/json; charset=utf-8',
            'svg' => 'image/svg+xml', 'xml' => 'application/xml; charset=utf-8',
            'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif',
            'webp' => 'image/webp', 'bmp' => 'image/bmp', 'ico' => 'image/x-icon', 'avif' => 'image/avif',
            'woff' => 'font/woff', 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', 'otf' => 'font/otf',
            'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm', 'ogv' => 'video/ogg', 'mov' => 'video/quicktime',
            'mp3' => 'audio/mpeg', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg', 'oga' => 'audio/ogg',
            'm4a' => 'audio/mp4', 'aac' => 'audio/aac', 'flac' => 'audio/flac',
            'pdf' => 'application/pdf', 'txt' => 'text/plain; charset=utf-8', 'md' => 'text/plain; charset=utf-8',
        ];
        // Anything else (incl. php/phtml) → plain text source, so it can never execute.
        return $map[$ext] ?? 'text/plain; charset=utf-8';
    }

    /**
     * Check a file's code for syntax errors. Body: { path, content? }.
     * Uses the file extension to pick a checker: PHP (php -l), JS (node --check),
     * JSON (json_decode), CSS (brace/comment scan), HTML (DOMDocument).
     * NOTE: linters only PARSE the code — user code is never executed.
     */
    public function lint(string $slug): void
    {
        $this->apiInit($slug);
        $in  = $this->input();
        $abs = $this->safe((string) ($in['path'] ?? ''));
        if ($abs === null) $this->fail('Invalid path.');
        $content = array_key_exists('content', $in)
            ? (string) $in['content']
            : (is_file($abs) ? (string) file_get_contents($abs) : '');
        $ext = strtolower((string) pathinfo($abs, PATHINFO_EXTENSION));

        $result = $this->lintContent($ext, $content);
        echo json_encode(['success' => true] + $result);
        exit;
    }

    /**
     * Execute a custom PHP file in a locked-down subprocess and return its output —
     * a real "Run" preview. This runs the OWNER's own project code (the same code the
     * build will run), never anonymous input. Sandbox: no shell/exec/network/mail,
     * filesystem confined to the project's custom/ dir + temp, 5s CPU + 6s wall
     * timeout, 64M memory, no remote includes. Best-effort isolation for a
     * self-hosted dev tool — not a hostile-multi-tenant boundary.
     */
    public function run(string $slug): void
    {
        $this->apiInit($slug);
        $in      = $this->input();
        $path    = (string) ($in['path'] ?? '');
        $content = (string) ($in['content'] ?? '');
        $ext     = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
        if (!in_array($ext, ['php', 'phtml'], true)) $this->fail('Only PHP files can be run.');

        $bin = PHP_BINARY !== '' && str_contains(strtolower(basename(PHP_BINARY)), 'php') && is_executable(PHP_BINARY)
            ? PHP_BINARY
            : trim((string) shell_exec('command -v php 2>/dev/null'));
        if ($bin === '') {
            echo json_encode(['success' => true, 'unavailable' => true, 'output' => '', 'stderr' => 'PHP CLI was not found on the server.']);
            exit;
        }
        if (!function_exists('proc_open')) {
            echo json_encode(['success' => true, 'unavailable' => true, 'output' => '', 'stderr' => 'proc_open is disabled on this server, so PHP cannot be run here.']);
            exit;
        }

        // Run in the target file's own directory so __DIR__ and relative paths behave.
        $abs = $this->safe($path);
        $dir = ($abs !== null && is_dir(dirname($abs))) ? dirname($abs) : $this->base();
        $this->reapStaleRunFiles();
        $tmp = $dir . '/.cfrun_' . bin2hex(random_bytes(6)) . '.php';
        if (@file_put_contents($tmp, $content) === false) $this->fail('Could not prepare the preview.');

        $disabled = implode(',', [
            'exec', 'passthru', 'shell_exec', 'system', 'proc_open', 'popen', 'pcntl_exec', 'pcntl_fork',
            'proc_nice', 'proc_terminate', 'proc_get_status', 'proc_close', 'dl', 'putenv',
            'mail', 'fsockopen', 'pfsockopen', 'stream_socket_client', 'stream_socket_server',
            'curl_exec', 'curl_multi_exec', 'symlink', 'link', 'apache_child_terminate',
            'posix_kill', 'posix_setuid', 'posix_mkfifo', 'phpinfo',
        ]);
        $openBase = $this->base() . PATH_SEPARATOR . sys_get_temp_dir();
        $args = [
            $bin,
            '-d', 'disable_functions=' . $disabled,
            '-d', 'open_basedir=' . $openBase,
            '-d', 'max_execution_time=5',
            '-d', 'memory_limit=64M',
            '-d', 'display_errors=stderr',
            '-d', 'html_errors=0',
            '-d', 'error_reporting=' . (string) E_ALL,
            '-d', 'allow_url_fopen=0',
            '-d', 'allow_url_include=0',
            $tmp,
        ];

        $start = microtime(true);
        [$out, $err, $timedOut] = $this->runProcess($args, $dir, 6.0);
        @unlink($tmp);
        $ms = (int) round((microtime(true) - $start) * 1000);

        // Never leak the temp path in messages — show the real file name.
        $shown = $path !== '' ? basename($path) : 'preview.php';
        $out = str_replace($tmp, $shown, $out);
        $err = str_replace([$tmp, basename($tmp)], $shown, $err);

        echo json_encode([
            'success'  => true,
            'output'   => $out,
            'stderr'   => trim($err),
            'timedOut' => $timedOut,
            'ms'       => $ms,
        ]);
        exit;
    }

    /**
     * Run a process (array argv — no shell) with a hard wall-clock timeout.
     * @param array<int,string> $args
     * @return array{0:string,1:string,2:bool} [stdout, stderr, timedOut]
     */
    private function runProcess(array $args, string $cwd, float $timeout): array
    {
        $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
        $env  = ['PATH' => getenv('PATH') ?: '/usr/bin:/bin:/usr/local/bin'];
        $proc = @proc_open($args, $desc, $pipes, $cwd, $env);
        if (!is_resource($proc)) return ['', 'Could not start the PHP process.', false];

        stream_set_blocking($pipes[1], false);
        stream_set_blocking($pipes[2], false);
        $out = ''; $err = ''; $timedOut = false;
        $deadline = microtime(true) + $timeout;
        while (true) {
            $status = proc_get_status($proc);
            $out .= (string) stream_get_contents($pipes[1]);
            $err .= (string) stream_get_contents($pipes[2]);
            if (!$status['running']) break;
            if (microtime(true) > $deadline) { $timedOut = true; @proc_terminate($proc, 9); break; }
            usleep(15000);
        }
        $out .= (string) stream_get_contents($pipes[1]);
        $err .= (string) stream_get_contents($pipes[2]);
        foreach ($pipes as $p) { if (is_resource($p)) @fclose($p); }
        @proc_close($proc);

        if (strlen($out) > 500000) $out = substr($out, 0, 500000) . "\n… (output truncated)";
        if (strlen($err) > 40000)  $err = substr($err, 0, 40000) . "\n… (truncated)";
        if ($timedOut) $err = trim($err . "\nExecution timed out and was stopped.");
        return [$out, $err, $timedOut];
    }

    /** Remove any orphaned .cfrun_* preview temp files (in case a run crashed before cleanup). */
    private function reapStaleRunFiles(): void
    {
        foreach (glob($this->base() . '/{,**/}.cfrun_*.php', GLOB_BRACE) ?: [] as $f) {
            if (is_file($f) && (time() - (int) @filemtime($f)) > 30) @unlink($f);
        }
    }

    /**
     * @return array{checked:bool, tool:string, problems:array<int, array{line:int|null, severity:string, message:string}>}
     */
    private function lintContent(string $ext, string $content): array
    {
        return match ($ext) {
            'php', 'phtml'        => $this->lintPhp($content),
            'js', 'mjs'           => $this->lintJs($content),
            'json'                => $this->lintJson($content),
            'css'                 => $this->lintCss($content),
            'html', 'htm'         => $this->lintHtml($content),
            default               => ['checked' => false, 'tool' => '', 'problems' => []],
        };
    }

    /** @return array{checked:bool, tool:string, problems:array<int, array<string, mixed>>} */
    private function lintPhp(string $content): array
    {
        $bin = PHP_BINARY !== '' && str_contains(strtolower(basename(PHP_BINARY)), 'php') && is_executable(PHP_BINARY)
            ? PHP_BINARY
            : trim((string) shell_exec('command -v php 2>/dev/null'));
        if ($bin === '') return ['checked' => false, 'tool' => 'php', 'problems' => []];

        $tmp = $this->tempFile($content, '.php');
        $out = (string) shell_exec(escapeshellarg($bin) . ' -d display_errors=1 -l ' . escapeshellarg($tmp) . ' 2>&1');
        @unlink($tmp);

        $problems = [];
        foreach (preg_split('/\r?\n/', $out) ?: [] as $line) {
            if (preg_match('/^(?:PHP\s+)?((?:Parse|Fatal)\s+error|Warning|Deprecated):\s*(.*?)\s+in\s+\S+\s+on\s+line\s+(\d+)/i', $line, $m)) {
                $sev = stripos($m[1], 'error') !== false ? 'error' : 'warning';
                $problems[] = ['line' => (int) $m[3], 'severity' => $sev, 'message' => trim($m[2])];
            }
        }
        // de-dupe (php -l prints the error twice: plain + "PHP " prefixed)
        $problems = array_values(array_intersect_key($problems, array_unique(array_map(
            static fn($p) => $p['line'] . '|' . $p['message'], $problems
        ))));
        return ['checked' => true, 'tool' => 'php -l', 'problems' => $problems];
    }

    /** @return array{checked:bool, tool:string, problems:array<int, array<string, mixed>>} */
    private function lintJs(string $content): array
    {
        $node = trim((string) shell_exec('command -v node 2>/dev/null'));
        if ($node === '') return ['checked' => false, 'tool' => 'node', 'problems' => []];

        $tmp = $this->tempFile($content, '.js');
        $out = (string) shell_exec(escapeshellarg($node) . ' --check ' . escapeshellarg($tmp) . ' 2>&1');
        @unlink($tmp);

        $problems = [];
        if (trim($out) !== '') {
            $line = preg_match('/\.js:(\d+)/', $out, $m) ? (int) $m[1] : null;
            $msg  = preg_match('/^([A-Za-z]*Error:\s*.*)$/m', $out, $m2) ? trim($m2[1]) : trim(strtok($out, "\n") ?: 'Syntax error');
            $problems[] = ['line' => $line, 'severity' => 'error', 'message' => $msg];
        }
        return ['checked' => true, 'tool' => 'node --check', 'problems' => $problems];
    }

    /** @return array{checked:bool, tool:string, problems:array<int, array<string, mixed>>} */
    private function lintJson(string $content): array
    {
        $problems = [];
        if (trim($content) === '') {
            $problems[] = ['line' => 1, 'severity' => 'error', 'message' => 'Empty document is not valid JSON'];
        } else {
            json_decode($content);
            if (json_last_error() !== JSON_ERROR_NONE) {
                $problems[] = ['line' => null, 'severity' => 'error', 'message' => json_last_error_msg()];
            }
        }
        return ['checked' => true, 'tool' => 'json', 'problems' => $problems];
    }

    /** Brace/comment/string scanner — catches the classic unbalanced-CSS mistakes. */
    private function lintCss(string $content): array
    {
        $problems = [];
        $depth = 0; $line = 1; $openLines = [];
        $inComment = false; $inString = ''; $len = strlen($content);
        for ($i = 0; $i < $len; $i++) {
            $c = $content[$i];
            if ($c === "\n") { $line++; continue; }
            if ($inComment) { if ($c === '*' && ($content[$i + 1] ?? '') === '/') { $inComment = false; $i++; } continue; }
            if ($inString !== '') { if ($c === $inString && ($content[$i - 1] ?? '') !== '\\') $inString = ''; continue; }
            if ($c === '/' && ($content[$i + 1] ?? '') === '*') { $inComment = true; $commentLine = $line; $i++; continue; }
            if ($c === '"' || $c === "'") { $inString = $c; continue; }
            if ($c === '{') { $depth++; $openLines[] = $line; }
            elseif ($c === '}') {
                if ($depth === 0) { $problems[] = ['line' => $line, 'severity' => 'error', 'message' => "Unexpected '}' — no matching '{'"]; }
                else { $depth--; array_pop($openLines); }
            }
        }
        if ($inComment) $problems[] = ['line' => $commentLine ?? 1, 'severity' => 'error', 'message' => 'Unclosed comment /* …'];
        if ($depth > 0)  $problems[] = ['line' => $openLines[0] ?? 1, 'severity' => 'error', 'message' => $depth . " unclosed '{' block(s) — first opened here"];
        return ['checked' => true, 'tool' => 'css scan', 'problems' => $problems];
    }

    /** @return array{checked:bool, tool:string, problems:array<int, array<string, mixed>>} */
    private function lintHtml(string $content): array
    {
        if (!class_exists(\DOMDocument::class)) return ['checked' => false, 'tool' => 'html', 'problems' => []];
        $problems = [];
        $prev = libxml_use_internal_errors(true);
        $doc = new \DOMDocument();
        @$doc->loadHTML($content);
        foreach (libxml_get_errors() as $err) {
            // 801 = unknown tag: libxml pre-dates HTML5 so <section>, <nav>… are
            // false positives; skip them. Keep genuine structure errors.
            if ((int) $err->code === 801) continue;
            if ($err->level < LIBXML_ERR_ERROR) continue;
            $problems[] = ['line' => (int) $err->line ?: null, 'severity' => 'error', 'message' => trim($err->message)];
        }
        libxml_clear_errors();
        libxml_use_internal_errors($prev);
        return ['checked' => true, 'tool' => 'html parse', 'problems' => array_slice($problems, 0, 25)];
    }

    private function tempFile(string $content, string $suffix): string
    {
        $tmp = tempnam(sys_get_temp_dir(), 'pplint');
        $named = $tmp . $suffix;
        @rename($tmp, $named);
        file_put_contents($named, $content);
        return $named;
    }

    // ── helpers ─────────────────────────────────────────────────────
    private function base(): string
    {
        // ProjectLocator, not the session — an owner/superadmin opening another
        // user's project must read and write that project's own custom folder
        // (same resolver the rest of the platform and the build use).
        $dir = \PpPhp\Core\ProjectLocator::dir($this->slug) . '/custom';
        if (!is_dir($dir)) @mkdir($dir, 0775, true);
        return $dir;
    }

    /** Resolve a relative path to an absolute path confined to base, or null if unsafe. */
    private function safe(string $rel): ?string
    {
        $rel = str_replace('\\', '/', $rel);
        $parts = [];
        foreach (explode('/', $rel) as $seg) {
            if ($seg === '' || $seg === '.') continue;
            if ($seg === '..') return null;
            if (preg_match('/[\x00-\x1f]/', $seg)) return null;
            $parts[] = $seg;
        }
        if ($parts === []) return $this->base();
        $abs = $this->base() . '/' . implode('/', $parts);
        $realBase = realpath($this->base());
        if ($realBase === false) return null;
        $check = file_exists($abs) ? realpath($abs) : realpath(dirname($abs));
        if ($check === false) return null;
        if ($check !== $realBase && !str_starts_with($check, $realBase . DIRECTORY_SEPARATOR)) return null;
        return $abs;
    }

    /** @return array<int, array<string, mixed>> */
    private function buildTree(string $dir, string $rel): array
    {
        $out = [];
        $entries = @scandir($dir) ?: [];
        // folders first, then files, both alphabetical
        $dirs = []; $files = [];
        foreach ($entries as $e) {
            if ($e === '.' || $e === '..') continue;
            if (str_starts_with($e, '.cfrun_')) continue;   // transient PHP-run preview temp files
            $abs = $dir . '/' . $e;
            $childRel = ($rel === '' ? '' : $rel . '/') . $e;
            if (is_dir($abs)) {
                $dirs[] = ['name' => $e, 'path' => $childRel, 'type' => 'dir', 'children' => $this->buildTree($abs, $childRel)];
            } else {
                $files[] = ['name' => $e, 'path' => $childRel, 'type' => 'file',
                    'size' => filesize($abs) ?: 0, 'ext' => strtolower((string) pathinfo($e, PATHINFO_EXTENSION)),
                    'reserved' => $this->isReserved($childRel)];
            }
        }
        usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
        usort($files, fn($a, $b) => strcasecmp($a['name'], $b['name']));
        foreach ($dirs as &$d) { $d['reserved'] = $this->isReserved($d['path']); }
        return array_merge($dirs, $files);
    }

    /** True when a relative path would clobber a generated system path at build time. */
    private function isReserved(string $rel): bool
    {
        $top = strtolower(explode('/', ltrim($rel, '/'))[0] ?? '');
        return in_array($top, array_map('strtolower', self::RESERVED_TOP), true);
    }

    private function rrmdir(string $path): void
    {
        if (is_file($path) || is_link($path)) { @unlink($path); return; }
        foreach (@scandir($path) ?: [] as $e) {
            if ($e === '.' || $e === '..') continue;
            $this->rrmdir($path . '/' . $e);
        }
        @rmdir($path);
    }

    /** @return array<string, mixed> */
    private function input(): array
    {
        $raw = file_get_contents('php://input');
        $json = json_decode((string) $raw, true);
        return is_array($json) ? $json : $_POST;
    }

    private function fail(string $message, int $code = 400): void
    {
        http_response_code($code);
        echo json_encode(['success' => false, 'error' => $message]);
        exit;
    }

    private function apiInit(string $slug): void
    {
        $this->initProject($slug);
        header('Content-Type: application/json');
    }

    private function initProject(string $slug): void
    {
        Security::RequireAuth();
        DB::Init();
        $project = DB::Select('projects', ['slug' => $slug, 'created_by' => Security::CurrentUserData()['id']]);
        if (empty($project) && Security::IsAdmin()) {
            $project = DB::Select('projects', ['slug' => $slug]);
        }
        if (empty($project)) {
            http_response_code(404);
            echo 'Project not found.';
            exit;
        }
        $this->project = $project[0];
        $this->slug = $slug;
    }
}
