<?php
/**
 * Site Builder API - Site-Scoped
 *
 * REST endpoints for the Craft.js site builder, tied to WHP sites.
 * Save writes to a staging area (.site-builder/); Publish copies to the live docroot.
 *
 * Actions:
 *   get_sites     - List user's WHP sites for the site picker
 *   list          - List site-builder projects for the user
 *   load          - Load project JSON for a specific site
 *   save          - Save project data to staging (.site-builder/)
 *   publish       - Compose full HTML and deploy to document root
 *   delete        - Delete site-builder data from a site
 *   upload_asset  - Upload a file to the site's staging assets directory
 *   list_assets   - List assets in the site's assets directory
 *   delete_asset  - Delete an asset file
 */

// Authentication is handled by auto-prepend.php
// AUTH_USER and HOME_DIR constants are already defined if logged in

// CORS headers
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-CSRF-Token');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(200);
    exit;
}

// serve_asset -- simple file proxy for when the domain isn't pointed to the server yet.
// No auth needed -- these are public assets in the site's document root.
// Once the domain is pointed, the site container serves them directly.
if (($_GET['action'] ?? '') === 'serve_asset') {
    $siteId   = intval($_GET['site_id'] ?? 0);
    $filename = basename($_GET['filename'] ?? '');

    if ($siteId <= 0 || empty($filename)) {
        http_response_code(400);
        exit;
    }

    // Kill session/buffering immediately so we get clean binary output
    if (session_status() === PHP_SESSION_ACTIVE) {
        session_write_close();
    }
    while (ob_get_level()) {
        ob_end_clean();
    }

    require_once(__DIR__ . '/../libs/mysqlmgmt.php');
    $MySQLMgmt = new mysqlmgmt();
    $pdo = $MySQLMgmt->getMySQLConnection();

    $stmt = $pdo->prepare("
        SELECT s.username, sd.fqdn, d.domain_name, ct.base_image
        FROM whp.sites s
        JOIN whp.site_domains sd ON s.id = sd.site_id AND sd.is_primary = 1
        JOIN whp.domains d ON sd.domain_id = d.id
        JOIN whp.container_types ct ON s.container_type_id = ct.id
        WHERE s.id = :site_id
    ");
    $stmt->execute([':site_id' => $siteId]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        http_response_code(404);
        exit;
    }

    $fqdn = $row['fqdn'] ?: $row['domain_name'];
    // Wildcard domains (*.example.com) are stored on disk as wildcard.example.com
    $dirName = (strpos($fqdn, '*.') === 0) ? 'wildcard.' . substr($fqdn, 2) : $fqdn;
    $baseDir = '/docker/users/' . $row['username'] . '/' . $dirName;
    $image = $row['base_image'] ?? '';
    $isPhp = (strpos($image, '/cac:') !== false || strpos($image, '/cac-fpm:') !== false);
    $docroot = $isPhp ? ($baseDir . '/public_html/') : ($baseDir . '/');

    // Look in staging area first, then fall back to docroot assets
    $filePath = $docroot . '.site-builder/assets/' . $filename;
    if (!file_exists($filePath)) {
        $filePath = $docroot . 'assets/' . $filename;
    }

    if (!file_exists($filePath)) {
        http_response_code(404);
        exit;
    }

    header_remove();
    $mime = mime_content_type($filePath) ?: 'application/octet-stream';
    $data = file_get_contents($filePath);
    header('Content-Type: ' . $mime);
    header('Content-Length: ' . strlen($data));
    header('Cache-Control: public, max-age=86400');
    echo $data;
    exit;
}

header('Content-Type: application/json');

// Check authentication
if (!defined('AUTH_USER') || !defined('HOME_DIR')) {
    http_response_code(401);
    echo json_encode(['success' => false, 'error' => 'Not authenticated']);
    exit;
}

// Database connection (same pattern as other API files)
require_once(__DIR__ . '/../libs/mysqlmgmt.php');
$MySQLMgmt = new mysqlmgmt();
$pdo = $MySQLMgmt->getMySQLConnection();

if (!$pdo) {
    http_response_code(500);
    echo json_encode(['success' => false, 'error' => 'Database connection failed']);
    exit;
}

$isRoot = (AUTH_USER === 'root');

// Get request method and action
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';

try {
    switch ($action) {
        case 'get_sites':
            handleGetSites($pdo, $isRoot);
            break;

        case 'list':
            handleList($pdo, $isRoot);
            break;

        case 'load':
            handleLoad($pdo, $isRoot);
            break;

        case 'save':
            handleSave($pdo, $isRoot);
            break;

        case 'publish':
            handlePublish($pdo, $isRoot);
            break;

        case 'delete':
            handleDelete($pdo, $isRoot);
            break;

        case 'upload_asset':
            handleUploadAsset($pdo, $isRoot);
            break;

        case 'list_assets':
            handleListAssets($pdo, $isRoot);
            break;

        case 'delete_asset':
            handleDeleteAsset($pdo, $isRoot);
            break;

        case 'serve_asset':
            handleServeAsset($pdo, $isRoot);
            break;

        case 'site_status':
            handleSiteStatus($pdo, $isRoot);
            break;

        case 'set_site_status':
            handleSetSiteStatus($pdo, $isRoot);
            break;

        default:
            http_response_code(400);
            echo json_encode([
                'success' => false,
                'error' => 'Invalid action. Supported: get_sites, list, load, save, publish, delete, upload_asset, list_assets, delete_asset'
            ]);
    }
} catch (Exception $e) {
    $code = $e->getCode();
    if ($code >= 400 && $code < 600) {
        http_response_code($code);
    } else {
        http_response_code(500);
    }
    echo json_encode([
        'success' => false,
        'error' => $e->getMessage()
    ]);
}

// ---------------------------------------------------------------------------
// Helper Functions
// ---------------------------------------------------------------------------

/**
 * Resolve and validate a site's document root, ensuring the current user owns it.
 *
/**
 * @return array ['docroot' => string, 'fqdn' => string, 'site' => array]
 * @throws Exception on validation failure
 */
function getSiteDocRoot($pdo, $site_id, $username, $is_root) {
    $site_id = (int)$site_id;
    if ($site_id <= 0) {
        throw new Exception('Invalid site ID', 400);
    }

    $sql = "SELECT s.id, s.site_name, s.username, s.active, sd.fqdn, d.domain_name,
                   ct.base_image, ct.name AS container_type_name
            FROM whp.sites s
            JOIN whp.site_domains sd ON s.id = sd.site_id AND sd.is_primary = 1
            JOIN whp.domains d ON sd.domain_id = d.id
            JOIN whp.container_types ct ON s.container_type_id = ct.id
            WHERE s.id = :site_id";

    if (!$is_root) {
        $sql .= " AND s.username = :username";
    }

    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':site_id', $site_id, PDO::PARAM_INT);
    if (!$is_root) {
        $stmt->bindValue(':username', $username, PDO::PARAM_STR);
    }
    $stmt->execute();
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        throw new Exception('Site not found or access denied', 404);
    }

    $fqdn = $row['fqdn'] ?: $row['domain_name'];
    // Wildcard domains (*.example.com) are stored on disk as wildcard.example.com
    $dirName = (strpos($fqdn, '*.') === 0) ? 'wildcard.' . substr($fqdn, 2) : $fqdn;
    $baseDir = '/docker/users/' . $row['username'] . '/' . $dirName;

    // Determine document root based on container type:
    // - PHP containers (cac:* and cac-fpm:*) mount to /home/{user} with public_html inside
    // - Static Apache (whp-default:*) mounts directly to htdocs -- the domain dir IS the docroot
    $image = $row['base_image'] ?? '';
    $isPhpContainer = (strpos($image, '/cac:') !== false || strpos($image, '/cac-fpm:') !== false);

    if ($isPhpContainer) {
        $docroot = $baseDir . '/public_html/';
    } else {
        $docroot = $baseDir . '/';
    }

    return [
        'docroot' => $docroot,
        'fqdn'    => $fqdn,
        'site'    => $row,
    ];
}

/**
 * Set ownership of a file or directory to the site's owner.
 * Uses shell chown -R for reliability across PHP configurations.
 */
function chownToSiteOwner($path, $username) {
    if (!file_exists($path)) return;
    $safePath = escapeshellarg($path);
    $safeUser = escapeshellarg($username);
    shell_exec("chown -R {$safeUser}:{$safeUser} {$safePath} 2>/dev/null");
}

/**
 * Generate a complete standalone HTML page with embedded CSS.
 */
function generateCompiledHTML($title, $css, $html) {
    $safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');

    // GrapesJS getHtml() may wrap content in <body>...</body> -- strip it to avoid duplication
    $html = preg_replace('#^\s*<body[^>]*>#i', '', $html);
    $html = preg_replace('#</body>\s*$#i', '', $html);

    // Baseline reset + responsive helpers. Mirrors the standalone ZIP export
    // (html-export.ts) — without these, the browser's default 8px body margin
    // shows up as a white frame around dark hero sections and column layouts
    // don't stack on mobile.
    $reset = "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}"
        . "html,body{overflow-x:hidden}"
        . "body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}"
        . "img{max-width:100%;height:auto;display:block}"
        . "a{color:inherit}"
        . "@media(max-width:768px){"
            . "[style*=\"display: flex\"][style*=\"flex-direction: row\"],"
            . "[style*=\"display:flex\"][style*=\"flex-direction:row\"]"
            . "{flex-direction:column !important}"
        . "}"
        . "@media(min-width:992px){[data-hide-desktop]{display:none !important}}"
        . "@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none !important}}"
        . "@media(max-width:767px){[data-hide-mobile]{display:none !important}}";

    return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{$safeTitle}</title>
    <style>
{$reset}
{$css}
    </style>
</head>
<body>
{$html}
</body>
</html>
HTML;
}

/**
 * Ensure the site_builder_projects table exists (safe no-op if already present).
 */
function ensureProjectsTable($pdo) {
    try {
        $pdo->exec("
            CREATE TABLE IF NOT EXISTS whp.site_builder_projects (
                id INT AUTO_INCREMENT PRIMARY KEY,
                site_id INT NOT NULL COMMENT 'FK to sites.id',
                username VARCHAR(32) NOT NULL COMMENT 'Owner username for quick lookups',
                name VARCHAR(255) NOT NULL DEFAULT 'Untitled Site' COMMENT 'Project display name',
                page_count INT DEFAULT 1 COMMENT 'Number of pages in the project',
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                UNIQUE KEY unique_site_project (site_id),
                INDEX idx_username (username),
                FOREIGN KEY (site_id) REFERENCES whp.sites(id) ON DELETE CASCADE
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
        ");
    } catch (PDOException $e) {
        // Table may already exist or FK may fail on some setups -- ignore
    }
}

// ---------------------------------------------------------------------------
// Action Handlers
// ---------------------------------------------------------------------------

/**
 * get_sites - Return the user's WHP sites for the site picker.
 */
function handleGetSites($pdo, $isRoot) {
    $sql = "SELECT s.id, s.site_name, s.active, sd.fqdn, d.domain_name
            FROM whp.sites s
            JOIN whp.site_domains sd ON s.id = sd.site_id AND sd.is_primary = 1
            JOIN whp.domains d ON sd.domain_id = d.id";

    if (!$isRoot) {
        $sql .= " WHERE s.username = :username";
    }

    $sql .= " ORDER BY s.site_name";

    $stmt = $pdo->prepare($sql);
    if (!$isRoot) {
        $stmt->bindValue(':username', AUTH_USER, PDO::PARAM_STR);
    }
    $stmt->execute();
    $sites = $stmt->fetchAll(PDO::FETCH_ASSOC);

    echo json_encode(['success' => true, 'sites' => $sites]);
}

/**
 * list - List site-builder projects for the user.
 */
function handleList($pdo, $isRoot) {
    $sql = "SELECT s.id AS site_id, s.site_name, s.active, sd.fqdn, d.domain_name,
                   sbp.updated_at, sbp.created_at
            FROM whp.site_builder_projects sbp
            JOIN whp.sites s ON sbp.site_id = s.id
            JOIN whp.site_domains sd ON s.id = sd.site_id AND sd.is_primary = 1
            JOIN whp.domains d ON sd.domain_id = d.id";

    if (!$isRoot) {
        $sql .= " WHERE s.username = :username";
    }

    $sql .= " ORDER BY sbp.updated_at DESC";

    try {
        $stmt = $pdo->prepare($sql);
        if (!$isRoot) {
            $stmt->bindValue(':username', AUTH_USER, PDO::PARAM_STR);
        }
        $stmt->execute();
        $projects = $stmt->fetchAll(PDO::FETCH_ASSOC);
    } catch (PDOException $e) {
        // Table may not exist yet
        $projects = [];
    }

    // Enrich with URL
    foreach ($projects as &$p) {
        $fqdn = $p['fqdn'] ?: $p['domain_name'];
        $p['url'] = 'http://' . $fqdn . '/';
    }
    unset($p);

    echo json_encode(['success' => true, 'projects' => $projects]);
}

/**
 * load - Load the project JSON for a specific WHP site.
 */
function handleLoad($pdo, $isRoot) {
    $siteId = $_GET['site_id'] ?? '';
    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $projectFile = $info['docroot'] . '.site-builder/project.json';

    if (!file_exists($projectFile)) {
        // Return empty project scaffold so the editor can start fresh
        echo json_encode([
            'success' => true,
            'project' => null,
            'site' => [
                'site_id'   => (int)$info['site']['id'],
                'site_name' => $info['site']['site_name'],
                'fqdn'      => $info['fqdn'],
                'url'        => 'http://' . $info['fqdn'] . '/',
            ],
        ]);
        return;
    }

    $json = file_get_contents($projectFile);
    $data = json_decode($json, true);

    if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
        throw new Exception('Corrupt project.json: ' . json_last_error_msg(), 500);
    }

    echo json_encode([
        'success' => true,
        'project' => $data,
        'site' => [
            'site_id'   => (int)$info['site']['id'],
            'site_name' => $info['site']['site_name'],
            'fqdn'      => $info['fqdn'],
            'url'        => 'http://' . $info['fqdn'] . '/',
        ],
    ]);
}

/**
 * save - Save project data to the staging area (.site-builder/).
 * Does NOT affect the live site. The staging area stores:
 *   - project.json (full Craft.js state for all pages, header, footer, design tokens)
 *   - pages/{filename} (body HTML for each page)
 *   - header.html (header HTML fragment)
 *   - footer.html (footer HTML fragment)
 *   - css/style.css (site-wide CSS from design tokens)
 *   - assets/ (uploaded files -- managed by upload_asset)
 */
function handleSave($pdo, $isRoot) {
    $input = file_get_contents('php://input');
    $data = json_decode($input, true);

    if (!$data) {
        throw new Exception('Invalid JSON body', 400);
    }

    $siteId = $data['site_id'] ?? '';
    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $docroot = $info['docroot'];

    // Ensure document root exists
    if (!is_dir($docroot)) {
        throw new Exception('Site document root does not exist', 500);
    }

    // Create .site-builder/ directory structure with protective .htaccess
    $sbDir = $docroot . '.site-builder/';
    $pagesDir = $sbDir . 'pages/';
    $cssDir = $sbDir . 'css/';

    foreach ([$sbDir, $pagesDir, $cssDir] as $dir) {
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
    }

    $htaccessFile = $sbDir . '.htaccess';
    if (!file_exists($htaccessFile)) {
        file_put_contents($htaccessFile, "Deny from all\n");
    }

    // Write project.json (full editor state)
    $projectJson = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    if (file_put_contents($sbDir . 'project.json', $projectJson) === false) {
        throw new Exception('Failed to write project.json', 500);
    }

    // Write header HTML fragment
    $headerHtml = $data['header_html'] ?? '';
    file_put_contents($sbDir . 'header.html', $headerHtml);

    // Write footer HTML fragment
    $footerHtml = $data['footer_html'] ?? '';
    file_put_contents($sbDir . 'footer.html', $footerHtml);

    // Write site-wide CSS
    $css = $data['css'] ?? '';
    file_put_contents($cssDir . 'style.css', $css);

    // Write body HTML for each page to .site-builder/pages/
    $pages = $data['pages'] ?? [];
    if (empty($pages) && !empty($data['html'])) {
        // Single-page fallback: wrap top-level html into a pages entry
        $pages = [[
            'filename' => 'index.html',
            'title'    => $data['name'] ?? $info['site']['site_name'],
            'html'     => $data['html'],
        ]];
    }

    $savedPages = [];
    $keepFilenames = [];
    foreach ($pages as $page) {
        $filename = basename($page['filename'] ?? 'index.html');
        if (!preg_match('/\.html?$/i', $filename)) {
            $filename .= '.html';
        }

        $html = $page['html'] ?? '';
        $targetPath = $pagesDir . $filename;

        if (file_put_contents($targetPath, $html) === false) {
            throw new Exception("Failed to write pages/{$filename}", 500);
        }
        $savedPages[] = $filename;
        $keepFilenames[$filename] = true;
    }

    // Remove stale .html files from pages/ — if the user deleted or renamed a
    // page, its old staging file would otherwise linger and get republished.
    foreach (glob($pagesDir . '*.html') ?: [] as $existing) {
        $bn = basename($existing);
        if (!isset($keepFilenames[$bn])) {
            @unlink($existing);
        }
    }

    // Upsert DB row in site_builder_projects
    $pageCount = count($pages);
    $projectName = $data['name'] ?? $info['site']['site_name'];
    try {
        ensureProjectsTable($pdo);
        $stmt = $pdo->prepare("
            INSERT INTO whp.site_builder_projects (site_id, username, name, page_count, created_at, updated_at)
            VALUES (:site_id, :username, :name, :page_count, NOW(), NOW())
            ON DUPLICATE KEY UPDATE name = VALUES(name), page_count = VALUES(page_count), updated_at = NOW()
        ");
        $stmt->bindValue(':site_id', (int)$siteId, PDO::PARAM_INT);
        $stmt->bindValue(':username', $info['site']['username'], PDO::PARAM_STR);
        $stmt->bindValue(':name', $projectName, PDO::PARAM_STR);
        $stmt->bindValue(':page_count', $pageCount, PDO::PARAM_INT);
        $stmt->execute();
    } catch (PDOException $e) {
        // Non-fatal: the file save succeeded even if the DB tracking fails
    }

    // Fix file ownership -- all staging files should be owned by the site's user
    $siteOwner = $info['site']['username'];
    chownToSiteOwner($sbDir, $siteOwner);

    echo json_encode([
        'success' => true,
        'draft'   => true,
        'pages'   => $savedPages,
        'site' => [
            'site_id'   => (int)$info['site']['id'],
            'site_name' => $info['site']['site_name'],
            'fqdn'      => $info['fqdn'],
        ],
    ]);
}

/**
 * publish - Compose full HTML from staging files and deploy to the document root.
 * Reads header.html, footer.html, css/style.css, and pages/* from .site-builder/,
 * composes complete HTML documents, and writes them to the live docroot.
 */
function handlePublish($pdo, $isRoot) {
    $input = file_get_contents('php://input');
    $data = json_decode($input, true) ?: [];

    $siteId = $data['site_id'] ?? ($_GET['site_id'] ?? '');
    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $docroot = $info['docroot'];
    $sbDir = $docroot . '.site-builder/';
    $pagesDir = $sbDir . 'pages/';

    // Verify staging area exists
    if (!is_dir($pagesDir)) {
        throw new Exception('No saved pages to publish. Save your site first.', 400);
    }

    // Read header and footer fragments
    $headerHtml = '';
    if (file_exists($sbDir . 'header.html')) {
        $headerHtml = file_get_contents($sbDir . 'header.html');
    }

    $footerHtml = '';
    if (file_exists($sbDir . 'footer.html')) {
        $footerHtml = file_get_contents($sbDir . 'footer.html');
    }

    // Read site-wide CSS
    $css = '';
    if (file_exists($sbDir . 'css/style.css')) {
        $css = file_get_contents($sbDir . 'css/style.css');
    }

    // Read project.json to get page titles
    $projectData = [];
    if (file_exists($sbDir . 'project.json')) {
        $projectData = json_decode(file_get_contents($sbDir . 'project.json'), true) ?: [];
    }
    $pageTitles = [];
    foreach (($projectData['pages'] ?? []) as $p) {
        $fn = basename($p['filename'] ?? '');
        if ($fn) {
            $pageTitles[$fn] = $p['title'] ?? '';
        }
    }

    // Process each page file from .site-builder/pages/ — but only those that
    // appear in the project's current pages array. This guards against stale
    // staging files left over from deleted/renamed pages.
    $generatedFiles = [];
    $allowed = [];
    foreach (($projectData['pages'] ?? []) as $p) {
        $fn = basename($p['filename'] ?? '');
        if ($fn) $allowed[$fn] = true;
    }
    $pageFiles = [];
    foreach (glob($pagesDir . '*.html') ?: [] as $pf) {
        if (!empty($allowed) && !isset($allowed[basename($pf)])) {
            continue; // stale leftover, skip
        }
        $pageFiles[] = $pf;
    }

    if (empty($pageFiles)) {
        throw new Exception('No HTML pages found in staging area.', 400);
    }

    // Asset URL rewrite pattern (proxy URLs -> relative paths for published HTML)
    $assetUrlPattern = '#/api/(?:assets-proxy/\?|site-builder\.php\?action=serve_asset(?:&amp;|&))site_id=[^&"\']+(?:&amp;|&)filename=([^&"\']+)[^"\']*#';

    foreach ($pageFiles as $pageFile) {
        $filename = basename($pageFile);
        $pageBody = file_get_contents($pageFile);

        // Compose full body: header + page content + footer
        $composedBody = $headerHtml . "\n" . $pageBody . "\n" . $footerHtml;

        // Rewrite proxy URLs to relative paths
        $composedBody = preg_replace($assetUrlPattern, 'assets/$1', $composedBody);
        $publishCss = preg_replace($assetUrlPattern, 'assets/$1', $css);

        // Look up title from project data
        $title = $pageTitles[$filename] ?? $info['site']['site_name'];

        // Generate complete HTML document
        $compiled = generateCompiledHTML($title, $publishCss, $composedBody);
        $targetPath = $docroot . $filename;

        if (file_put_contents($targetPath, $compiled) === false) {
            throw new Exception("Failed to write {$filename} to document root", 500);
        }
        $generatedFiles[] = $filename;
    }

    // Clean up stale pages from previous publishes (e.g., a page that used to
    // be saved as home.html but is now index.html after the landing-page
    // normalization, or a renamed/deleted page). We only touch files that we
    // know we generated in a prior publish, so user-uploaded HTML in the
    // docroot is never deleted.
    $publishedManifest = $sbDir . 'published.json';
    $previouslyGenerated = [];
    if (file_exists($publishedManifest)) {
        $prev = json_decode(file_get_contents($publishedManifest), true);
        if (is_array($prev) && isset($prev['files']) && is_array($prev['files'])) {
            $previouslyGenerated = $prev['files'];
        }
    }
    $currentSet = array_flip($generatedFiles);
    foreach ($previouslyGenerated as $oldFile) {
        $oldFile = basename($oldFile);
        if ($oldFile === '' || isset($currentSet[$oldFile])) continue;
        $oldPath = $docroot . $oldFile;
        if (is_file($oldPath)) {
            @unlink($oldPath);
        }
    }
    @file_put_contents(
        $publishedManifest,
        json_encode(['files' => $generatedFiles, 'published_at' => time()], JSON_PRETTY_PRINT)
    );

    // Copy staging assets to docroot assets (if the staging assets dir exists)
    $stagingAssetsDir = $sbDir . 'assets/';
    $liveAssetsDir = $docroot . 'assets/';
    if (is_dir($stagingAssetsDir)) {
        if (!is_dir($liveAssetsDir)) {
            mkdir($liveAssetsDir, 0755, true);
        }
        // Copy all asset files
        $assetFiles = scandir($stagingAssetsDir);
        foreach ($assetFiles as $af) {
            if ($af === '.' || $af === '..' || $af === '.htaccess') continue;
            $src = $stagingAssetsDir . $af;
            $dst = $liveAssetsDir . $af;
            if (is_file($src)) {
                copy($src, $dst);
            }
        }
    }

    // Write .htaccess for clean URLs (drop .html extension)
    $htaccessPath = $docroot . '.htaccess';
    if (!file_exists($htaccessPath)) {
        $htaccess = <<<'HTACCESS'
RewriteEngine On

# Clean URLs: /about loads about.html
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+)$ $1.html [L]
HTACCESS;
        file_put_contents($htaccessPath, $htaccess);
    }

    // Update DB row with publish timestamp
    $pageCount = count($generatedFiles);
    $projectName = $projectData['name'] ?? $info['site']['site_name'];
    try {
        ensureProjectsTable($pdo);

        // Ensure published_at column exists
        try {
            $pdo->exec("ALTER TABLE whp.site_builder_projects ADD COLUMN published_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at");
        } catch (PDOException $e) {
            // Column already exists -- ignore
        }

        $stmt = $pdo->prepare("
            INSERT INTO whp.site_builder_projects (site_id, username, name, page_count, created_at, updated_at, published_at)
            VALUES (:site_id, :username, :name, :page_count, NOW(), NOW(), NOW())
            ON DUPLICATE KEY UPDATE name = VALUES(name), page_count = VALUES(page_count), updated_at = NOW(), published_at = NOW()
        ");
        $stmt->bindValue(':site_id', (int)$siteId, PDO::PARAM_INT);
        $stmt->bindValue(':username', $info['site']['username'], PDO::PARAM_STR);
        $stmt->bindValue(':name', $projectName, PDO::PARAM_STR);
        $stmt->bindValue(':page_count', $pageCount, PDO::PARAM_INT);
        $stmt->execute();
    } catch (PDOException $e) {
        // Non-fatal
    }

    // Fix file ownership on all generated files
    $siteOwner = $info['site']['username'];
    chownToSiteOwner($sbDir, $siteOwner);
    foreach ($generatedFiles as $gf) {
        chownToSiteOwner($docroot . $gf, $siteOwner);
    }
    if (is_dir($liveAssetsDir)) {
        chownToSiteOwner($liveAssetsDir, $siteOwner);
    }
    chownToSiteOwner($htaccessPath, $siteOwner);

    $siteUrl = 'http://' . $info['fqdn'] . '/';

    echo json_encode([
        'success'      => true,
        'published'    => true,
        'url'          => $siteUrl,
        'files'        => $generatedFiles,
        'published_at' => date('c'),
        'site' => [
            'site_id'   => (int)$info['site']['id'],
            'site_name' => $info['site']['site_name'],
            'fqdn'      => $info['fqdn'],
        ],
    ]);
}

/**
 * delete - Delete site-builder data from a site.
 */
function handleDelete($pdo, $isRoot) {
    $siteId = $_GET['site_id'] ?? ($_REQUEST['site_id'] ?? '');
    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $docroot = $info['docroot'];
    $sbDir = $docroot . '.site-builder/';

    // Read project.json to find generated HTML files
    $projectFile = $sbDir . 'project.json';
    $removedFiles = [];

    if (file_exists($projectFile)) {
        $projectData = json_decode(file_get_contents($projectFile), true);
        if ($projectData && isset($projectData['pages'])) {
            foreach ($projectData['pages'] as $page) {
                $filename = basename($page['filename'] ?? '');
                if ($filename !== '' && file_exists($docroot . $filename)) {
                    unlink($docroot . $filename);
                    $removedFiles[] = $filename;
                }
            }
        }
    }

    // Remove .site-builder/ directory recursively.
    //
    // SECURITY: $sbDir lives inside the customer-owned docroot under
    // /docker/users/<user>/<domain>/. Any logged-in WHP user who owns the
    // site can plant a symlink inside .site-builder/ (e.g.
    // .site-builder/etc -> /etc) and trigger this delete endpoint. The
    // previous implementation walked the tree with RecursiveDirectoryIterator
    // (which follows symlinks during recursion) and called unlink()/rmdir()
    // on $fileinfo->getRealPath() -- which RESOLVES the symlink, so the
    // operation would land on the real host path. That is the same
    // destruction class as the cpanel removeDirectory bug fixed in f7e4cbc.
    //
    // Two layers of defense, either of which alone closes the vector:
    //
    //   1. Containment check: the realpath() of $sbDir must stay under
    //      /docker/users/, the only legitimate prefix for per-site docroots.
    //      Defeats $sbDir itself being a symlink to /etc.
    //
    //   2. Shell rm -rf via escapeshellarg. Coreutils rm does NOT follow
    //      symlinks during recursion -- it unlinks the link itself and leaves
    //      the target untouched. Matches CpanelBackupImporter::removeDirectory().
    if (is_dir($sbDir) || is_link(rtrim($sbDir, '/'))) {
        $realSb = realpath($sbDir);
        if (strncmp($sbDir, '/docker/users/', 14) !== 0) {
            error_log("site-builder delete refused: '$sbDir' not under /docker/users/");
        } elseif ($realSb !== false && strncmp($realSb, '/docker/users/', 14) !== 0) {
            error_log("site-builder delete refused: '$sbDir' realpath '$realSb' escapes /docker/users/");
        } else {
            $cmd = 'rm -rf ' . escapeshellarg($sbDir);
            $out = [];
            $rc = 0;
            exec($cmd . ' 2>&1', $out, $rc);
            if ($rc !== 0) {
                error_log("site-builder delete: rm -rf failed for '$sbDir' (rc=$rc): " . implode("\n", $out));
            }
        }
    }

    // NOTE: assets/ directory is intentionally NOT removed

    // Delete DB row
    try {
        $stmt = $pdo->prepare("DELETE FROM whp.site_builder_projects WHERE site_id = :site_id");
        $stmt->bindValue(':site_id', (int)$siteId, PDO::PARAM_INT);
        $stmt->execute();
    } catch (PDOException $e) {
        // Table may not exist -- non-fatal
    }

    echo json_encode([
        'success' => true,
        'message' => 'Site builder data deleted',
        'removed_files' => $removedFiles,
    ]);
}

/**
 * upload_asset - Upload a file to the site's assets directory.
 */
function handleUploadAsset($pdo, $isRoot) {
    $siteId = $_POST['site_id'] ?? ($_GET['site_id'] ?? '');
    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    if (empty($_FILES['file'])) {
        throw new Exception('No file uploaded', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $docroot = $info['docroot'];

    // Allowed extensions (whitelist)
    $allowedExtensions = [
        // Images
        'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg',
        // Video
        'mp4', 'webm', 'ogg',
        // Documents
        'pdf',
    ];

    $file = $_FILES['file'] ?? null;
    if (!$file) {
        // GrapesJS may send files as file[] array
        $file = $_FILES['files'] ?? null;
    }
    if (!$file) {
        throw new Exception('No file in upload', 400);
    }

    // Handle array uploads (take the first file)
    $originalName = is_array($file['name']) ? $file['name'][0] : $file['name'];
    $tmpPath = is_array($file['tmp_name']) ? $file['tmp_name'][0] : $file['tmp_name'];
    $fileError = is_array($file['error']) ? $file['error'][0] : $file['error'];

    if ($fileError !== UPLOAD_ERR_OK) {
        throw new Exception('Upload error code: ' . $fileError, 400);
    }

    // Validate extension
    $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
    if (!in_array($ext, $allowedExtensions, true)) {
        throw new Exception('File type not allowed. Allowed: ' . implode(', ', $allowedExtensions), 400);
    }

    // Create assets directory in staging area
    $sbDir = $docroot . '.site-builder/';
    if (!is_dir($sbDir)) {
        mkdir($sbDir, 0755, true);
    }
    $assetsDir = $sbDir . 'assets/';
    if (!is_dir($assetsDir)) {
        mkdir($assetsDir, 0755, true);
    }

    // Generate unique filename
    $sanitized = preg_replace('/[^a-zA-Z0-9_\-.]/', '_', pathinfo($originalName, PATHINFO_FILENAME));
    $sanitized = substr($sanitized, 0, 100); // Limit length
    $uniqueName = time() . '_' . bin2hex(random_bytes(4)) . '_' . $sanitized . '.' . $ext;

    $destPath = $assetsDir . $uniqueName;

    if (!move_uploaded_file($tmpPath, $destPath)) {
        throw new Exception('Failed to save uploaded file', 500);
    }

    // Optimize raster images: downscale to 2560px max width, prefer WebP, fall back
    // to recompressed original format if WebP is unavailable or larger than the source.
    if (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true)) {
        $optimized = optimizeUploadedImage($destPath, $ext);
        if ($optimized !== null && $optimized !== $destPath) {
            $uniqueName = basename($optimized);
            $destPath = $optimized;
        }
    }

    // Fix ownership to site owner
    chownToSiteOwner($assetsDir, $info['site']['username']);

    // Detect MIME type
    $mimeType = mime_content_type($destPath) ?: 'application/octet-stream';

    // Proxy URL served without auto-prepend -- works in GrapesJS iframe and preview
    $proxyUrl = '/api/assets-proxy/?site_id=' . urlencode($siteId) . '&filename=' . urlencode($uniqueName);

    echo json_encode([
        'success' => true,
        'url'     => $proxyUrl,
        'name'    => $originalName,
        'type'    => $mimeType,
    ]);
}

/**
 * Optimize a just-uploaded raster image: downscale wide images and re-encode.
 *
 * Tries WebP first (~30% smaller than JPEG at equivalent quality, universal
 * browser support). Falls back to the original format if WebP isn't available
 * or doesn't actually shrink the file. Returns the final on-disk path on
 * success, or null on any failure (in which case the caller's original file
 * is left untouched).
 */
function optimizeUploadedImage(string $path, string $ext): ?string {
    if (!extension_loaded('gd')) return null;
    // Guard against decoding huge files that could OOM the PHP worker.
    $info = @getimagesize($path);
    if (!$info) return null;
    [$srcW, $srcH] = $info;
    if ($srcW * $srcH > 60_000_000) return null; // ~60MP cap

    $img = null;
    if ($ext === 'jpg' || $ext === 'jpeg') $img = @imagecreatefromjpeg($path);
    elseif ($ext === 'png') $img = @imagecreatefrompng($path);
    elseif ($ext === 'webp') $img = @imagecreatefromwebp($path);
    if (!$img) return null;

    $maxW = 2560;
    if ($srcW > $maxW) {
        $newW = $maxW;
        $newH = (int)round($srcH * ($maxW / $srcW));
        $resized = imagecreatetruecolor($newW, $newH);
        imagealphablending($resized, false);
        imagesavealpha($resized, true);
        imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH);
        imagedestroy($img);
        $img = $resized;
    }
    imagealphablending($img, false);
    imagesavealpha($img, true);

    $origSize = filesize($path);
    $dir = dirname($path);
    $base = pathinfo($path, PATHINFO_FILENAME);

    // Try WebP first. Adopt it whenever encoding succeeds and the file isn't
    // pathologically large (>1.5x the source) — the user has asked for WebP as
    // the default for new uploads, falling back only when WebP isn't viable.
    if (function_exists('imagewebp')) {
        $webpPath = $dir . '/' . $base . '.webp';
        if (@imagewebp($img, $webpPath, 80)) {
            $webpSize = @filesize($webpPath) ?: 0;
            if ($webpSize > 0 && $webpSize < $origSize * 1.5) {
                if ($webpPath !== $path) @unlink($path);
                imagedestroy($img);
                return $webpPath;
            }
            @unlink($webpPath);
        }
    }

    // Fall back: recompress in original format if it shrinks the file.
    $tmp = $path . '.opt';
    $reencoded = false;
    if ($ext === 'jpg' || $ext === 'jpeg') {
        $reencoded = @imagejpeg($img, $tmp, 85);
    } elseif ($ext === 'png') {
        $reencoded = @imagepng($img, $tmp, 9);
    } elseif ($ext === 'webp') {
        $reencoded = @imagewebp($img, $tmp, 85);
    }
    imagedestroy($img);
    if ($reencoded) {
        $newSize = @filesize($tmp) ?: 0;
        if ($newSize > 0 && $newSize < $origSize) {
            rename($tmp, $path);
            return $path;
        }
        @unlink($tmp);
    }
    return null;
}

/**
 * list_assets - List assets in the site's assets directory.
 */
function handleListAssets($pdo, $isRoot) {
    $siteId = $_GET['site_id'] ?? '';
    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $assetsDir = $info['docroot'] . '.site-builder/assets/';

    $assets = [];

    if (is_dir($assetsDir)) {
        $entries = scandir($assetsDir);
        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $fullPath = $assetsDir . $entry;
            if (!is_file($fullPath)) {
                continue;
            }
            $proxyUrl = '/api/assets-proxy/?site_id=' . urlencode($siteId) . '&filename=' . urlencode($entry);
            $assets[] = [
                'name'        => $entry,
                'url'         => $proxyUrl,
                'type'        => mime_content_type($fullPath) ?: 'application/octet-stream',
                'size'        => filesize($fullPath),
                'modified'    => filemtime($fullPath),
            ];
        }

        // Sort newest first
        usort($assets, function ($a, $b) {
            return $b['modified'] - $a['modified'];
        });
    }

    echo json_encode(['success' => true, 'assets' => $assets]);
}

/**
 * delete_asset - Delete an asset file.
 */
function handleDeleteAsset($pdo, $isRoot) {
    $siteId = $_GET['site_id'] ?? ($_REQUEST['site_id'] ?? '');
    $filename = $_GET['filename'] ?? ($_REQUEST['filename'] ?? '');

    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }
    if (empty($filename)) {
        throw new Exception('filename is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);

    // Prevent path traversal -- assets live in staging area
    $safeFilename = basename($filename);
    $filePath = $info['docroot'] . '.site-builder/assets/' . $safeFilename;

    if (!file_exists($filePath)) {
        throw new Exception('Asset not found', 404);
    }

    if (!unlink($filePath)) {
        throw new Exception('Failed to delete asset', 500);
    }

    echo json_encode([
        'success' => true,
        'message' => 'Asset deleted',
    ]);
}

/**
 * serve_asset - Proxy an asset file from the site's document root.
 * This allows the editor (served from /site-builder/) to display assets
 * that live in the site's document root.
 */
function handleServeAsset($pdo, $isRoot) {
    $siteId = $_GET['site_id'] ?? '';
    $filename = $_GET['filename'] ?? '';

    if (empty($siteId) || empty($filename)) {
        throw new Exception('site_id and filename are required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);

    // Prevent path traversal -- serve from staging area during editing
    $safeFilename = basename($filename);
    $filePath = $info['docroot'] . '.site-builder/assets/' . $safeFilename;

    // Fallback to docroot assets if not found in staging (for backwards compat)
    if (!file_exists($filePath)) {
        $filePath = $info['docroot'] . 'assets/' . $safeFilename;
    }

    if (!file_exists($filePath)) {
        http_response_code(404);
        // Return a 1x1 transparent gif so broken images don't look terrible
        header('Content-Type: image/gif');
        echo base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
        exit;
    }

    // Remove JSON content-type set earlier
    header_remove('Content-Type');

    $mime = mime_content_type($filePath) ?: 'application/octet-stream';
    $size = filesize($filePath);

    header('Content-Type: ' . $mime);
    header('Content-Length: ' . $size);
    header('Cache-Control: public, max-age=3600');

    readfile($filePath);
    exit;
}

/**
 * site_status - Check if a site is in "coming soon" mode.
 * Coming soon is indicated by a .site-builder/coming-soon flag file.
 */
function handleSiteStatus($pdo, $isRoot) {
    $siteId = $_GET['site_id'] ?? '';
    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $flagFile = $info['docroot'] . '.site-builder/coming-soon';

    echo json_encode([
        'success'     => true,
        'coming_soon' => file_exists($flagFile),
        'site_id'     => (int)$siteId,
    ]);
}

/**
 * set_site_status - Toggle coming soon mode for a site.
 * When enabled: backs up index.html and replaces it with a coming soon page.
 * When disabled: restores the original index.html.
 */
function handleSetSiteStatus($pdo, $isRoot) {
    $input = json_decode(file_get_contents('php://input'), true);
    $siteId = $input['site_id'] ?? '';
    $comingSoon = !empty($input['coming_soon']);

    if (empty($siteId)) {
        throw new Exception('site_id is required', 400);
    }

    $info = getSiteDocRoot($pdo, $siteId, AUTH_USER, $isRoot);
    $docroot = $info['docroot'];
    $sbDir = $docroot . '.site-builder/';
    $flagFile = $sbDir . 'coming-soon';
    $indexFile = $docroot . 'index.html';
    $backupFile = $sbDir . 'index.html.live';

    // Ensure .site-builder directory exists
    if (!is_dir($sbDir)) {
        mkdir($sbDir, 0755, true);
        file_put_contents($sbDir . '.htaccess', "Deny from all\n");
    }

    if ($comingSoon) {
        // Enable coming soon mode
        // Back up the current index.html if it exists and isn't already a coming soon page
        if (file_exists($indexFile) && !file_exists($flagFile)) {
            copy($indexFile, $backupFile);
        }

        // Write coming soon page
        $siteName = htmlspecialchars($info['site']['site_name'] ?: $info['fqdn'], ENT_QUOTES, 'UTF-8');
        $comingSoonHtml = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{$siteName} - Coming Soon</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            color: #e2e8f0;
        }
        .container {
            text-align: center;
            padding: 2rem;
            max-width: 600px;
        }
        h1 {
            font-size: 3rem;
            font-weight: 700;
            margin-bottom: 1rem;
            background: linear-gradient(135deg, #3b82f6, #8b5cf6);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
        }
        p {
            font-size: 1.25rem;
            color: #94a3b8;
            line-height: 1.6;
        }
        .dot {
            display: inline-block;
            width: 8px;
            height: 8px;
            background: #3b82f6;
            border-radius: 50%;
            margin: 0 4px;
            animation: pulse 1.5s ease-in-out infinite;
        }
        .dot:nth-child(2) { animation-delay: 0.3s; }
        .dot:nth-child(3) { animation-delay: 0.6s; }
        @keyframes pulse {
            0%, 100% { opacity: 0.3; transform: scale(1); }
            50% { opacity: 1; transform: scale(1.2); }
        }
        .dots { margin-top: 2rem; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Coming Soon</h1>
        <p>We're working on something great. Check back soon!</p>
        <div class="dots">
            <span class="dot"></span>
            <span class="dot"></span>
            <span class="dot"></span>
        </div>
    </div>
</body>
</html>
HTML;

        file_put_contents($indexFile, $comingSoonHtml);
        touch($flagFile); // Create flag file

        // Fix ownership
        chownToSiteOwner($sbDir, $info['site']['username']);
        chownToSiteOwner($indexFile, $info['site']['username']);

    } else {
        // Disable coming soon mode - restore original index.html
        if (file_exists($backupFile)) {
            copy($backupFile, $indexFile);
            unlink($backupFile);
        }
        // Remove flag file
        if (file_exists($flagFile)) {
            unlink($flagFile);
        }
    }

    echo json_encode([
        'success'     => true,
        'coming_soon' => $comingSoon,
        'message'     => $comingSoon ? 'Site set to Coming Soon mode' : 'Site is now live',
    ]);
}
