<?php

declare(strict_types=1);

namespace PpPhp\Modules\Finished\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\DB;
use PpPhp\Core\Security;
use PpPhp\Core\ProjectNav;
use PpPhp\Core\ModuleState;
use PpPhp\Core\Build\BuildEngine;

class FinishedController
{
    private array $project = [];
    private string $slug = '';

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

    public function index(string $slug): string
    {
        $this->initProject($slug);
        $project = $this->project;

        $state = ModuleState::load($slug, 'finished') ?: $this->defaultState();
        $outputState = ModuleState::load($slug, 'output') ?: [];

        $navItems  = ProjectNav::items('/finished');
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = $_SESSION['user'];
        $pageTitle = 'Finished';

        $stateJson   = json_encode($state);
        $outputJson  = json_encode($outputState);

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

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

        $input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
        $data  = is_array($input['data'] ?? null) ? $input['data'] : [];
        $clear = !empty($input['clear']);

        // lastBuild/history are server-managed (written by the build action).
        // Protect them from being clobbered by stale client state — the client
        // may only reset them with an explicit clear flag.
        $stored = ModuleState::load($slug, 'finished');
        if (!$clear) {
            if (empty($data['lastBuild']) && !empty($stored['lastBuild'])) {
                $data['lastBuild'] = $stored['lastBuild'];
            }
            if (count($data['history'] ?? []) < count($stored['history'] ?? [])) {
                $data['history'] = $stored['history'];
            }
        }

        ModuleState::save($slug, 'finished', $data);
        echo json_encode(['success' => true, 'message' => 'State saved.']);
        exit;
    }

    public function action(string $slug, string $action): void
    {
        header('Content-Type: application/json');
        @set_time_limit(300);
        ini_set('display_errors', '0');
        $this->initProject($slug);

        $state = ModuleState::load($slug, 'finished') ?: $this->defaultState();
        $outputState = ModuleState::load($slug, 'output') ?: [];
        $input = json_decode((string) file_get_contents('php://input'), true) ?: [];

        // Pre-build safety net: scan every event once. ERRORS (code that won't
        // even parse) block the build — unless the user forces it; WARNINGS and
        // NOTES never block but are surfaced on the success screen.
        $eventReport = null;
        if ($action === 'build') {
            $eventReport = \PpPhp\Core\Events\EventScanner::scan($slug);
            $errors = (int) ($eventReport['totals']['error'] ?? 0);
            if ($errors > 0 && empty($input['force'])) {
                echo json_encode([
                    'success' => false,
                    'gated'   => true,
                    'report'  => $eventReport,
                    'message' => 'Build blocked: ' . $errors . ' event ' . ($errors === 1 ? 'error' : 'errors') . ' to fix first.',
                ]);
                exit;
            }
        }

        $result = match ($action) {
            'build'     => $this->runBuild($slug, $state, $outputState),
            'preview'   => $this->runPreview($slug, $outputState, $state),
            'open-folder' => $this->runOpenFolder($slug, $outputState),
            'deploy-demo' => $this->runDeployDemo($slug, $input, $outputState, $state),
            'deploy-git' => $this->runDeployGit($this->resolveOutputDir($slug, $outputState, $state)),
            'package-zip' => $this->runPackageZip($slug, $this->resolveOutputDir($slug, $outputState, $state)),
            'deploy-package' => $this->runDeployPackage($slug, $outputState),
            'generate-sql' => $this->runGenerateSql($slug),
            'health-check' => $this->runHealthCheck($this->resolveOutputDir($slug, $outputState, $state)),
            'reset-dashboard' => $this->runResetDashboard($slug, $state, $outputState),
            default => ['success' => false, 'message' => "Unknown action: $action"],
        };

        $actionsThatUpdateHistory = ['build', 'deploy-demo', 'deploy-git'];
        if ($result['success'] && in_array($action, $actionsThatUpdateHistory)) {
            $state['history'][] = [
                'action'   => $action,
                'status'   => 'success',
                'message'  => $result['message'],
                'time'     => date('c'),
                'duration' => $result['duration'] ?? 0,
            ];
            if ($action === 'build') {
                // Persist the resolved output directory back to Output module state
                if (!empty($result['outputDir'])) {
                    $outputState['general']['outputDir'] = $result['outputDir'];
                    ModuleState::save($slug, 'output', $outputState);
                }
                // Record the build (files + path→hash manifest) as the CENTRAL,
                // authoritative build record shared by Output / Finished / Deploy.
                \PpPhp\Core\Deploy\BuildRecorder::record(
                    (int) ($this->project['id'] ?? 0),
                    (string) ($result['outputDir'] ?? ''),
                    (float) ($result['duration'] ?? 0),
                    (int) (Security::CurrentUserData()['id'] ?? 0) ?: null
                );
                // Read the figures back from the central record so every module
                // shows identical numbers (never the transient per-action calc).
                $central = \PpPhp\Core\Deploy\BuildRecorder::latestInfo((int) ($this->project['id'] ?? 0));
                $lastLoc = $this->buildLocation((string) ($central['output_dir'] ?? ($result['outputDir'] ?? '')));
                $state['lastBuild'] = [
                    'time'      => date('c'),
                    'status'    => 'success',
                    'files'     => $central['files'] ?? ($result['files'] ?? 0),
                    'duration'  => $central['duration'] ?? ($result['duration'] ?? 0),
                    'size'      => $central['size'] ?? ($result['size'] ?? '—'),
                    'outputDir' => $central['output_dir'] ?? ($result['outputDir'] ?? ''),
                    'outputRelative' => $lastLoc['relative'],
                    'outputUrl' => $lastLoc['url'],
                ];
                $result['files'] = $state['lastBuild']['files'];
                $result['size']  = $state['lastBuild']['size'];
                // Surface the event-scan advisories (non-blocking) on the success
                // screen so a clean build still nudges the author toward warnings.
                if (is_array($eventReport)) {
                    $result['eventScan'] = [
                        'warnings'      => (int) ($eventReport['totals']['warning'] ?? 0),
                        'notes'         => (int) ($eventReport['totals']['info'] ?? 0),
                        'forcedErrors'  => !empty($input['force']) ? (int) ($eventReport['totals']['error'] ?? 0) : 0,
                    ];
                }
                // Mark a restorable checkpoint each time the project is built.
                \PpPhp\Core\VersionHistory::snapshot($slug, 'Checkpoint', 'build');
            }
            ModuleState::save($slug, 'finished', $state);
            // Return the authoritative state so the client can re-render from truth
            $result['state'] = $state;
        }

        echo json_encode($result);
        exit;
    }

    /**
     * Stream a one-time generated file (ZIP / SQL) to the browser and delete it
     * from the server straight afterwards, so nothing is left lying around.
     */
    public function download(string $slug, string $token): void
    {
        $this->initProject($slug);

        $token = preg_replace('/[^a-f0-9]/', '', $token);
        $entry = $_SESSION['pp_downloads'][$token] ?? null;
        unset($_SESSION['pp_downloads'][$token]); // one-time use

        if (!is_array($entry) || !is_file((string) $entry['path']) || (int) $entry['exp'] < time()) {
            http_response_code(404);
            echo 'This download has expired. Please generate it again.';
            exit;
        }

        $path = (string) $entry['path'];
        while (ob_get_level() > 0) {
            ob_end_clean();
        }
        header('Content-Type: ' . ($entry['type'] ?? 'application/octet-stream'));
        header('Content-Disposition: attachment; filename="' . basename((string) $entry['name']) . '"');
        header('Content-Length: ' . filesize($path));
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: no-store');
        readfile($path);
        @unlink($path); // remove from the server once sent
        exit;
    }

    /** Register a temp file for one-time download; returns its URL. */
    private function makeDownload(string $slug, string $path, string $filename, string $type): string
    {
        $token = bin2hex(random_bytes(16));
        if (!isset($_SESSION['pp_downloads']) || !is_array($_SESSION['pp_downloads'])) {
            $_SESSION['pp_downloads'] = [];
        }
        $_SESSION['pp_downloads'][$token] = [
            'path' => $path,
            'name' => $filename,
            'type' => $type,
            'exp'  => time() + 900, // 15 minutes
        ];
        return $this->config->get('base_url') . '/project/' . rawurlencode($slug) . '/finished/download/' . $token;
    }

    /**
     * True only when the browser is on the same machine as PPphp (local dev).
     * Actions that touch the desktop (open a folder, spawn a dev server) only
     * make sense there — on a hosted server they'd act on the server, not the user.
     */
    private function isLocalRequest(): bool
    {
        return in_array((string) ($_SERVER['REMOTE_ADDR'] ?? ''), ['127.0.0.1', '::1'], true);
    }

    // ── Action stubs ────────────────────────────────────────────────

    /**
     * Reset Dashboard to Factory Default. Restores the project's shipped
     * `factory/config/designer.json` snapshot (the canonical dashboard design)
     * over the current one — backing the current file up first — then rebuilds
     * so the change is live. Available only when the project ships a factory
     * snapshot (see projects/<owner>/<slug>/factory/). Owner-gated via initProject.
     */
    private function runResetDashboard(string $slug, array $state, array $outputState): array
    {
        $projDir = \PpPhp\Core\ProjectLocator::dir($slug);
        $factory = $projDir . '/factory/config/designer.json';
        $config  = $projDir . '/config/designer.json';
        if (!is_file($factory)) {
            return ['success' => false, 'message' => 'This project has no factory snapshot to restore (no factory/config/designer.json).'];
        }
        // Back up the current design before overwriting (reversible).
        if (is_file($config)) {
            $backupDir = $projDir . '/config/_pre_reset_' . date('Ymd_His');
            @mkdir($backupDir, 0775, true);
            @copy($config, $backupDir . '/designer.json');
        }
        if (!@copy($factory, $config)) {
            return ['success' => false, 'message' => 'Could not restore the factory design (check file permissions).'];
        }
        // Rebuild so the restored dashboard goes live.
        $build = $this->runBuild($slug, $state, $outputState);
        if (!($build['success'] ?? false)) {
            return ['success' => false, 'message' => 'Design restored, but the rebuild failed: ' . ($build['message'] ?? 'unknown error'), 'logs' => $build['logs'] ?? []];
        }
        $build['message'] = 'Dashboard reset to factory default and rebuilt. ' . ($build['message'] ?? '');
        return $build;
    }

    private function runBuild(string $slug, array $state, array $outputState): array
    {
        $options = [
            'clean'    => $outputState['general']['cleanBeforeBuild'] ?? false,
            'compress' => $outputState['optimization']['compressCss'] ?? false,
        ];
        // A CLIENT edition ships no generator — it builds on the licence mesh.
        // On the vendor's own server/dev build this is false, so nothing changes:
        // BuildEngine runs locally exactly as before.
        if (\PpPhp\Core\Licensing\LicenseGate::isClientEdition()
            && class_exists(\PpPhp\Core\Build\BuildClient::class)) {
            $result = (new \PpPhp\Core\Build\BuildClient())->build($slug, $options);
        } else {
            $result = (new BuildEngine())->build($slug, $options);
        }

        if (!$result->success) {
            return [
                'success' => false,
                'message' => $result->message,
                'logs'    => $result->logs,
            ];
        }

        $loc = $this->buildLocation((string) $result->outputDir);

        return [
            'success'  => true,
            'message'  => $result->message,
            'duration' => $result->duration,
            'files'    => $result->filesGenerated,
            'size'     => $result->size,
            'outputDir' => $result->outputDir,           // absolute — server-side ops only
            'outputRelative' => $loc['relative'],        // e.g. "output/app1" (safe to show)
            'outputUrl' => $loc['url'],                  // e.g. "https://host/output/app1" or ''
            'logs'     => $result->logs,
            'pages'    => $result->pages,
            'routes'   => count($result->routes),
        ];
    }

    /**
     * Turn an absolute build directory into display-safe location fields:
     *  - relative: the path relative to the app root (e.g. "output/app1") — never
     *    leaks the server's absolute filesystem path;
     *  - url: the public URL where the generated app is reachable, computed from
     *    the CURRENT request (scheme + host) and the build's position under the web
     *    document root (e.g. "https://ppphp.ppradhan.com/output/app1"). Empty only
     *    when the build genuinely does not live under the web root (the secure
     *    "output above the docroot" layout) or the host is unknown.
     *
     * Robust to shared-hosting quirks: the build path comes from __DIR__ (a real
     * path, e.g. /home2/…) while $_SERVER['DOCUMENT_ROOT'] may report the symlinked
     * home (e.g. /home/…). We match on the real path, the raw path, AND — if those
     * never line up — by anchoring on the document root's own folder name.
     */
    private function buildLocation(string $absDir): array
    {
        $norm = static fn (string $p): string => rtrim(str_replace('\\', '/', $p), '/');

        $absRaw = $norm($absDir);
        if ($absRaw === '') {
            return ['relative' => '', 'url' => ''];
        }
        $abs = $norm(realpath($absRaw) ?: $absRaw);

        // Display path relative to the app root — never the absolute filesystem path.
        $base = $norm(defined('BASE_PATH') ? (string) constant('BASE_PATH') : dirname(__DIR__, 3));
        $relative = ($base !== '' && str_starts_with($abs . '/', $base . '/'))
            ? ltrim(substr($abs, strlen($base)), '/')
            : basename($abs);

        // Public URL from the live request (works on any host — no config needed).
        $url = '';
        $host = (string) ($_SERVER['HTTP_HOST'] ?? '');
        if ($host !== '') {
            $https = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')
                || strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'
                || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443;
            $scheme = $https ? 'https' : 'http';

            $docRaw = $norm((string) ($_SERVER['DOCUMENT_ROOT'] ?? ''));
            $webPath = '';

            // (1) Direct prefix match. Try real/raw docroot × real/raw build path so a
            //     symlinked home (/home ↔ /home2) still lines up. Also covers app-in-
            //     subdirectory installs (the subdir stays in the web path).
            $docForms = array_values(array_unique(array_filter([
                $docRaw !== '' ? $norm(realpath($docRaw) ?: $docRaw) : '',
                $docRaw,
            ])));
            $absForms = array_values(array_unique(array_filter([$abs, $absRaw])));
            foreach ($docForms as $d) {
                foreach ($absForms as $a) {
                    if ($d !== '' && str_starts_with($a . '/', $d . '/')) {
                        $webPath = substr($a, strlen($d));
                        break 2;
                    }
                }
            }

            // (2) Basename anchor — if realpath could not reconcile a symlinked home,
            //     find the docroot's own folder name inside the build path and take
            //     everything after it (e.g. DOCUMENT_ROOT ".../public_html" vs build
            //     "/home2/.../public_html/output/app1" → "/output/app1").
            if ($webPath === '' && $docRaw !== '') {
                $docBase = basename($docRaw);
                if ($docBase !== '' && $docBase !== '/' && $docBase !== '.') {
                    $pos = strrpos($abs, '/' . $docBase . '/');
                    if ($pos !== false) {
                        $webPath = substr($abs, $pos + strlen($docBase) + 1);
                    }
                }
            }

            if ($webPath !== '') {
                $webPath = '/' . ltrim(str_replace('\\', '/', $webPath), '/');
                $encoded = implode('/', array_map('rawurlencode', explode('/', $webPath)));
                $url = $scheme . '://' . $host . $encoded;
            }
        }

        return ['relative' => $relative, 'url' => $url];
    }

    private function resolveOutputDir(string $slug, array $outputState, array $finishedState = []): string
    {
        $outputDir = $outputState['general']['outputDir'] ?? '';

        if (!empty($finishedState['lastBuild']['outputDir'])) {
            $outputDir = $finishedState['lastBuild']['outputDir'];
        }

        if (empty($outputDir)) {
            $outputDir = BASE_PATH . '/output/' . $slug;
        } elseif (!str_starts_with($outputDir, '/')) {
            $outputDir = BASE_PATH . '/' . $outputDir;
        }

        return $outputDir;
    }

    private function isPortAvailable(int $port): bool
    {
        $sock = @fsockopen('127.0.0.1', $port, $errno, $errstr, 1);
        if ($sock) {
            fclose($sock);
            return false;
        }
        return true;
    }

    private function runPreview(string $slug, array $outputState, array $finishedState): array
    {
        $outputDir  = $this->resolveOutputDir($slug, $outputState, $finishedState);
        $previewUrl = $outputState['general']['previewUrl'] ?? '';

        // A configured Preview URL always wins — it works locally and on a live
        // server, and is the recommended setup for hosted installs.
        if (!empty($previewUrl)) {
            return ['success' => true, 'message' => 'Opening your preview…', 'url' => $previewUrl];
        }

        if (empty($outputDir) || !is_dir($outputDir)) {
            return ['success' => false, 'message' => 'Output directory does not exist. Build the project first.'];
        }

        // Spawning PHP's built-in dev server only makes sense on a local install.
        // On a hosted server it would start on the server (usually firewalled) and
        // the user could never reach it — point them at the Preview URL instead.
        if (!$this->isLocalRequest()) {
            return [
                'success' => false,
                'message' => 'Preview needs a Preview URL when PPphp runs on a hosted server. '
                    . 'Set Output → Preview URL to the address where your built app is served, then click Preview again.',
            ];
        }

        $port = 8085;
        $lockFile = sys_get_temp_dir() . "/php-server-{$slug}.pid";
        $existingPid = @file_get_contents($lockFile);

        if ($existingPid > 0 && function_exists('posix_kill')) {
            if (@posix_kill((int)$existingPid, 0)) {
                $url = "http://localhost:$port";
                return ['success' => true, 'message' => "Server already running at <a href='$url' target='_blank'>$url</a>", 'url' => $url];
            }
        } elseif ($existingPid > 0) {
            $alive = @file_get_contents("http://127.0.0.1:$port") !== false;
            if ($alive) {
                $url = "http://localhost:$port";
                return ['success' => true, 'message' => "Server already running at <a href='$url' target='_blank'>$url</a>", 'url' => $url];
            }
        }

        // Remove stale lock file
        if ($existingPid) {
            @unlink($lockFile);
        }

        // Find an available port
        $maxAttempts = 5;
        for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
            if ($this->isPortAvailable($port)) {
                break;
            }
            $port++;
        }
        if (!$this->isPortAvailable($port)) {
            return ['success' => false, 'message' => "Ports $port–" . ($port + $maxAttempts - 1) . " are all in use. Free a port or configure a custom preview URL."];
        }

        $pidFile = tempnam(sys_get_temp_dir(), "php-server-{$slug}-");
        $cmd = sprintf(
            'php -S 0.0.0.0:%d -t %s > /dev/null 2>&1 & echo $! > %s',
            $port,
            escapeshellarg($outputDir),
            escapeshellarg($pidFile)
        );
        shell_exec($cmd);
        usleep(300000);

        $pid = @file_get_contents($pidFile);
        @unlink($pidFile);
        $pid = (int)$pid;

        if ($pid <= 0) {
            return ['success' => false, 'message' => 'Failed to start PHP built-in server. Check that PHP is installed and available as "php".'];
        }

        file_put_contents($lockFile, (string)$pid);

        $url = "http://localhost:$port";
        return [
            'success' => true,
            'message' => "Built-in server started at <a href='$url' target='_blank'>$url</a>",
            'url'     => $url,
            'pid'     => $pid,
        ];
    }

    private function runOpenFolder(string $slug, array $outputState): array
    {
        // The folder lives on the machine running PPphp. If that isn't the user's
        // own computer, opening it would pop a window on the server — useless and
        // not something we allow.
        if (!$this->isLocalRequest()) {
            return [
                'success' => false,
                'message' => 'Opening the output folder is only available when PPphp runs on your own computer. '
                    . 'On a hosted server the files live on the server — use “Package as ZIP” to download them, '
                    . 'or “Deploy via FTP” to browse them on the remote host.',
            ];
        }

        $finishedState = ModuleState::load($slug, 'finished') ?: [];
        $outputDir = $this->resolveOutputDir($slug, $outputState, $finishedState);

        if (empty($outputDir) || !is_dir($outputDir)) {
            return ['success' => false, 'message' => 'Output directory does not exist. Build the project first.'];
        }

        $real = realpath($outputDir);
        if ($real === false) {
            return ['success' => false, 'message' => 'Cannot resolve output directory path.'];
        }

        $escaped = escapeshellarg($real);
        shell_exec("open $escaped 2>/dev/null || xdg-open $escaped 2>/dev/null || start $escaped 2>/dev/null");
        return ['success' => true, 'message' => "Opened folder: $real", 'path' => $real];
    }

    /**
     * Publish the built app to a demo host using credentials supplied in the
     * Deploy-to-Demo dialog. Reuses the Deploy module's transport + manager, so
     * FTP/FTPS/SFTP all work exactly as they do in the full FTP client.
     * Nothing is stored — these credentials are used for this upload only.
     */
    private function runDeployDemo(string $slug, array $input, array $outputState, array $state): array
    {
        $host = trim((string) ($input['host'] ?? ''));
        if ($host === '') {
            return ['success' => false, 'message' => 'Enter your demo server details first.', 'needsCredentials' => true];
        }

        $protocol = in_array(($input['protocol'] ?? 'ftp'), ['ftp', 'ftps', 'sftp'], true) ? $input['protocol'] : 'ftp';
        $port     = (int) ($input['port'] ?? 0);
        if ($port <= 0) {
            $port = $protocol === 'sftp' ? 22 : 21;
        }

        $outputDir = $this->resolveOutputDir($slug, $outputState, $state);
        if (empty($outputDir) || !is_dir($outputDir)) {
            return ['success' => false, 'message' => 'Build the project first — there is nothing to publish yet.'];
        }

        $profile = [
            'protocol'   => $protocol,
            'host'       => $host,
            'port'       => $port,
            'user'       => (string) ($input['user'] ?? ''),
            'pass'       => (string) ($input['pass'] ?? ''),
            'remotePath' => trim((string) ($input['path'] ?? '')),
            'passive'    => true,
        ];

        $start = microtime(true);
        try {
            $manager = new \PpPhp\Core\Deploy\DeployManager();
            $result  = $manager->deploy($slug, $profile, [
                'mode'         => \PpPhp\Core\Deploy\DeployManager::MODE_ALL_OUTPUT,
                'outputDir'    => $outputDir,
                'connectionId' => 'demo',
                'projectId'    => (int) ($this->project['id'] ?? 0),
                'createdBy'    => (int) (Security::CurrentUserData()['id'] ?? 0) ?: null,
            ]);
        } catch (\Throwable $e) {
            return ['success' => false, 'message' => 'Demo publish failed: ' . $e->getMessage()];
        }

        $duration = round(microtime(true) - $start, 2);
        $result['duration'] = $result['duration'] ?? $duration;
        if (!empty($result['success']) && empty($result['message'])) {
            $result['message'] = "Published to {$host} in {$duration}s.";
        }
        return $result;
    }

    private function runDeployGit(string $outputDir): array
    {
        if (empty($outputDir) || !is_dir($outputDir)) {
            return ['success' => false, 'message' => 'Output directory does not exist. Build the project first.'];
        }

        if (trim((string) shell_exec('git --version 2>/dev/null')) === '') {
            return ['success' => false, 'message' => 'Git is not installed on this server, so a snapshot cannot be created.'];
        }

        $start = microtime(true);
        $dir   = escapeshellarg($outputDir);

        $created = false;
        if (!is_dir(rtrim($outputDir, '/') . '/.git')) {
            shell_exec("cd $dir && git init -q 2>&1");
            $created = true;
        }

        $out = (string) shell_exec(sprintf(
            'cd %s && git add -A 2>&1 && git -c user.name="PPphp" -c user.email="ppphp@localhost" commit -m %s 2>&1',
            $dir,
            escapeshellarg('Build ' . date('Y-m-d H:i:s'))
        ));

        $duration = round(microtime(true) - $start, 2);
        $nothing  = str_contains($out, 'nothing to commit') || str_contains($out, 'nothing added to commit');
        $hash     = trim((string) shell_exec("cd $dir && git rev-parse --short HEAD 2>/dev/null"));
        $count    = (int) trim((string) shell_exec("cd $dir && git rev-list --count HEAD 2>/dev/null"));

        if ($nothing) {
            return [
                'success'  => true,
                'message'  => 'Nothing to save — the output folder has not changed since the last snapshot' . ($hash !== '' ? " ({$hash})" : '') . '.',
                'duration' => $duration,
                'output'   => $out,
            ];
        }

        // Be explicit about what actually happened: this is a LOCAL git snapshot
        // of the generated files. Nothing is pushed anywhere.
        $msg = ($created ? 'Started a local Git history and saved' : 'Saved')
            . " snapshot {$hash} of your generated files"
            . ($count > 0 ? " (snapshot #{$count})" : '')
            . " in {$outputDir}. This is a local Git repository on the server — nothing was pushed to GitHub or any remote.";

        return [
            'success'  => true,
            'message'  => $msg,
            'duration' => $duration,
            'commit'   => $hash,
            'total'    => $count,
            'path'     => $outputDir,
            'output'   => $out,
        ];
    }

    private function runPackageZip(string $slug, string $outputDir): array
    {
        if (empty($outputDir) || !is_dir($outputDir)) {
            return ['success' => false, 'message' => 'Output directory does not exist. Build the project first.'];
        }

        if (!extension_loaded('zip')) {
            return ['success' => false, 'message' => 'The PHP ZIP extension is not installed on this server.'];
        }

        $filename = $slug . '_' . date('Ymd_His') . '.zip';
        $zipPath  = sys_get_temp_dir() . '/ppphp_' . bin2hex(random_bytes(6)) . '.zip';
        $start = microtime(true);

        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            return ['success' => false, 'message' => 'Failed to create the ZIP archive.'];
        }

        $root  = rtrim($outputDir, '/');
        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        $count = 0;
        foreach ($files as $file) {
            if ($file->isDir()) {
                continue;
            }
            $relative = substr($file->getPathname(), strlen($root) + 1);
            if ($relative === '' || str_starts_with($relative, '.git/')) {
                continue;
            }
            $zip->addFile($file->getPathname(), $relative);
            $count++;
        }
        $zip->close();

        if (!is_file($zipPath) || $count === 0) {
            @unlink($zipPath);
            return ['success' => false, 'message' => 'Nothing to package — build the project first.'];
        }

        $duration = round(microtime(true) - $start, 2);
        $size = (int) filesize($zipPath);

        return [
            'success'  => true,
            'message'  => "Your download is starting — {$count} files, "
                . \PpPhp\Core\Deploy\BuildRecorder::humanSize($size) . " (packaged in {$duration}s).",
            'duration' => $duration,
            'files'    => $count,
            'size'     => $size,
            'download' => $this->makeDownload($slug, $zipPath, $filename, 'application/zip'),
            'filename' => $filename,
        ];
    }

    /**
     * Build a self-installing DEPLOYMENT PACKAGE: the app + install.php + schema.sql
     * + seed.sql, with NO baked database credentials or dev admin. The operator
     * uploads it, opens the URL, and the wizard configures the DB, imports the
     * schema (tables + portable views) and data, and creates the first admin — a
     * reliable, credential-free deploy regardless of the Output installer toggle.
     */
    private function runDeployPackage(string $slug, array $outputState): array
    {
        if (!extension_loaded('zip')) {
            return ['success' => false, 'message' => 'The PHP ZIP extension is not installed on this server.'];
        }

        $start   = microtime(true);
        $tempDir = sys_get_temp_dir() . '/ppphp_deploy_' . bin2hex(random_bytes(6));

        // Build a fresh, installer-mode copy into a throwaway dir (never touches the
        // project's normal output). forceInstaller → ships install.php + a sample
        // config with no credentials; seed.sql carries data, not a dev admin hash.
        $buildOptions = [
            'clean'          => true,
            'compress'       => $outputState['optimization']['compressCss'] ?? false,
            'outputDir'      => $tempDir,
            'forceInstaller' => true,
        ];
        // A CLIENT edition ships no generator — build the installer-mode copy on the
        // licence mesh (BuildClient forwards forceInstaller + outputDir). On the
        // vendor's own server/dev this is false, so BuildEngine runs locally as before.
        if (\PpPhp\Core\Licensing\LicenseGate::isClientEdition()
            && class_exists(\PpPhp\Core\Build\BuildClient::class)) {
            $result = (new \PpPhp\Core\Build\BuildClient())->build($slug, $buildOptions);
        } else {
            $result = (new BuildEngine())->build($slug, $buildOptions);
        }
        if (!$result->success) {
            $this->rrmdir($tempDir);
            return ['success' => false, 'message' => 'Build failed: ' . $result->message, 'logs' => $result->logs];
        }

        $filename = $slug . '_deploy_' . date('Ymd_His') . '.zip';
        $zipPath  = sys_get_temp_dir() . '/ppphp_' . bin2hex(random_bytes(6)) . '.zip';
        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            $this->rrmdir($tempDir);
            return ['success' => false, 'message' => 'Failed to create the ZIP archive.'];
        }
        $root  = rtrim($tempDir, '/');
        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        $count = 0;
        foreach ($files as $file) {
            if ($file->isDir()) continue;
            $relative = substr($file->getPathname(), strlen($root) + 1);
            if ($relative === '' || str_starts_with($relative, '.git/')) continue;
            // Never ship real credentials or an install lock in a deploy package.
            if ($relative === 'config/database.php' || $relative === 'config/installed.lock') continue;
            // BuildClient (server-side build on a client) drops a delta manifest into
            // the output dir — internal bookkeeping, not part of the deploy package.
            if ($relative === '.ppmanifest.json') continue;
            $zip->addFile($file->getPathname(), $relative);
            $count++;
        }
        $zip->close();
        $size = is_file($zipPath) ? (int) filesize($zipPath) : 0;
        $this->rrmdir($tempDir);

        if ($count === 0 || $size === 0) {
            @unlink($zipPath);
            return ['success' => false, 'message' => 'Nothing to package.'];
        }

        $duration = round(microtime(true) - $start, 2);
        return [
            'success'  => true,
            'message'  => "Deployment package ready — {$count} files, "
                . \PpPhp\Core\Deploy\BuildRecorder::humanSize($size)
                . ". Upload it, open the URL, and the installer will set up the database + admin.",
            'duration' => $duration,
            'files'    => $count,
            'size'     => $size,
            'download' => $this->makeDownload($slug, $zipPath, $filename, 'application/zip'),
            'filename' => $filename,
        ];
    }

    /** Recursively remove a directory (throwaway build dirs). */
    private function rrmdir(string $dir): void
    {
        if (!is_dir($dir)) return;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($it as $f) {
            $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
        }
        @rmdir($dir);
    }

    private function runGenerateSql(string $slug): array
    {
        $start = microtime(true);

        $tablesState = ModuleState::load($slug, 'tables') ?: [];
        $selected = $tablesState['selectedTables'] ?? [];

        try {
            if (empty($selected)) {
                $rows = DB::Query('SHOW FULL TABLES')->fetchAll(\PDO::FETCH_NUM);
                $selected = array_map(static fn($r) => $r[0], $rows);
            }
        } catch (\Throwable $e) {
            return ['success' => false, 'message' => 'Could not read project tables: ' . $e->getMessage()];
        }
        if (empty($selected)) {
            return ['success' => false, 'message' => 'No tables found in the project database.'];
        }

        $sql = "-- Schema for project: $slug\n-- Generated " . date('c') . " by PPphp\n\nSET FOREIGN_KEY_CHECKS = 0;\n\n";
        $dumped = [];
        foreach ($selected as $table) {
            $safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $table);
            if ($safe === '') continue;
            try {
                $row = DB::Query("SHOW CREATE TABLE `{$safe}`")->fetchAssoc();
                if (isset($row['Create View'])) {
                    // A VIEW: strip DEFINER so it imports on any server, and DROP the
                    // right object type.
                    $ddl = \PpPhp\Core\Database\SqlPortability::view((string) $row['Create View']);
                    $sql .= "DROP VIEW IF EXISTS `{$safe}`;\n{$ddl};\n\n";
                    $dumped[] = $safe;
                    continue;
                }
                $create = $row['Create Table'] ?? null;
                if ($create === null) continue;
                $sql .= "DROP TABLE IF EXISTS `{$safe}`;\n{$create};\n\n";
                $dumped[] = $safe;
            } catch (\Throwable $e) {
                $sql .= "-- Skipped `{$safe}`: {$e->getMessage()}\n\n";
            }
        }
        $sql .= "SET FOREIGN_KEY_CHECKS = 1;\n";

        // Written to a temp file, streamed straight to the browser, then removed
        // from the server — nothing is left behind in the output folder.
        $filename = $slug . '_schema_' . date('Ymd_His') . '.sql';
        $sqlPath  = sys_get_temp_dir() . '/ppphp_' . bin2hex(random_bytes(6)) . '.sql';
        if (file_put_contents($sqlPath, $sql) === false) {
            return ['success' => false, 'message' => 'Could not create the SQL file on the server.'];
        }

        $duration = round(microtime(true) - $start, 2);
        return [
            'success'  => true,
            'message'  => 'Your download is starting — schema for ' . count($dumped) . " table(s) (built in {$duration}s).",
            'duration' => $duration,
            'tables'   => $dumped,
            'download' => $this->makeDownload($slug, $sqlPath, $filename, 'application/sql'),
            'filename' => $filename,
        ];
    }

    private function runHealthCheck(string $outputDir = ''): array
    {
        $checks = [];

        $checks[] = ['name' => 'PHP Version', 'status' => PHP_VERSION_ID >= 80100 ? 'pass' : 'fail', 'value' => PHP_VERSION];
        $checks[] = ['name' => 'PDO MySQL', 'status' => extension_loaded('pdo_mysql') ? 'pass' : 'fail', 'value' => extension_loaded('pdo_mysql') ? 'Loaded' : 'Missing'];
        $checks[] = ['name' => 'ZIP Extension', 'status' => extension_loaded('zip') ? 'pass' : 'fail', 'value' => extension_loaded('zip') ? 'Loaded' : 'Missing'];
        $checks[] = ['name' => 'JSON Extension', 'status' => extension_loaded('json') ? 'pass' : 'fail', 'value' => extension_loaded('json') ? 'Loaded' : 'Missing'];
        $checks[] = ['name' => 'MBString', 'status' => extension_loaded('mbstring') ? 'pass' : 'fail', 'value' => extension_loaded('mbstring') ? 'Loaded' : 'Missing'];
        $checks[] = ['name' => 'GD/Imagick', 'status' => extension_loaded('gd') || extension_loaded('imagick') ? 'pass' : 'warn', 'value' => extension_loaded('gd') ? 'GD' : (extension_loaded('imagick') ? 'Imagick' : 'Not installed')];
        $checks[] = ['name' => 'OpenSSL', 'status' => extension_loaded('openssl') ? 'pass' : 'fail', 'value' => extension_loaded('openssl') ? 'Loaded' : 'Missing'];
        if ($outputDir !== '') {
            $parent = is_dir($outputDir) ? $outputDir : dirname($outputDir);
            if (is_dir($outputDir) && is_writable($outputDir)) {
                $checks[] = ['name' => 'Output Directory', 'status' => 'pass', 'value' => $outputDir];
            } elseif (is_dir($parent) && is_writable($parent)) {
                $checks[] = ['name' => 'Output Directory', 'status' => 'pass', 'value' => "Will be created: $outputDir"];
            } else {
                $checks[] = ['name' => 'Output Directory', 'status' => 'fail', 'value' => "Not writable: $outputDir"];
            }
        } else {
            $checks[] = ['name' => 'Output Directory', 'status' => 'warn', 'value' => 'Not configured — set it in the Output module'];
        }
        $checks[] = ['name' => 'PHP Built-in Server', 'status' => 'pass', 'value' => 'Available (PHP ' . PHP_VERSION . ')'];
        $checks[] = ['name' => 'Server OS', 'status' => 'pass', 'value' => PHP_OS_FAMILY];

        $allPass = count(array_filter($checks, fn($c) => $c['status'] === 'fail')) === 0;
        $hasWarn = count(array_filter($checks, fn($c) => $c['status'] === 'warn')) > 0;
        $overall = $allPass ? ($hasWarn ? 'warning' : 'healthy') : 'failed';

        return [
            'success' => true,
            'status'  => $overall,
            'checks'  => $checks,
        ];
    }

    // ── Project initialisation ─────────────────────────────────────

    private function initProject(string $slug): void
    {
        Security::RequireAuth();
        $this->slug = $slug;

        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];
        // Standalone (database-free) projects have no project DB — the build,
        // preview and packaging all work from files only. Skip the connection so
        // it doesn't fatal (which would return a non-JSON response to the client).
        if (\PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug)) {
            return;
        }
        DB::Init($slug);
        DB::SetConnection($slug);
    }

    private function defaultState(): array
    {
        return [
            'lastBuild' => null,
            'history'   => [],
        ];
    }
}
