<?php

namespace WHPBackup;

require_once(__DIR__ . '/../configs/config.php');
require_once('/docker/whp/web/libs/mysqlmgmt.php');
require_once('BackupTarget.php');
require_once('BackupHistory.php');
require_once('BackupStorage.php');
require_once('BackupLocalStorage.php');

use Exception;

class BackupJob {
    private $db;
    private $tempDir;
    private $localStorage;
    // Half the system's logical cores (min 1) — keeps pigz from saturating
    // CPU while it runs alongside live traffic on shared hosts.
    private $pigzThreads;

    private static function backup_log($message) {
        $timestamp = date('Y-m-d H:i:s');
        $message = str_replace(["\r", "\n"], ' ', $message);
        $formatted = "[$timestamp] [BackupJob] $message";
        error_log($formatted);
        @file_put_contents('/var/log/whp-backup.log', $formatted . "\n", FILE_APPEND | LOCK_EX);
    }

    public function __construct() {
        $mysql = new \mysqlmgmt();
        $this->db = $mysql->getMySQLConnection();
        $this->db->exec("USE whp");
        $this->tempDir = '/docker/tmp';
        $this->localStorage = new BackupLocalStorage($this->db);

        if (!is_dir($this->tempDir)) {
            mkdir($this->tempDir, 0755, true);
        }

        $this->pigzThreads = self::detectPigzThreads();
    }

    /**
     * Read logical CPU count from /proc/cpuinfo and return half (min 1) for
     * pigz's -p flag. Hard-coding to /proc keeps this off the shell.
     */
    private static function detectPigzThreads() {
        $cpuinfo = @file_get_contents('/proc/cpuinfo');
        if (!is_string($cpuinfo) || $cpuinfo === '') {
            return 1;
        }
        $cores = preg_match_all('/^processor\s*:/m', $cpuinfo);
        if ($cores < 1) {
            return 1;
        }
        return max(1, intdiv($cores, 2));
    }

    // Pipeline modes for runPipeline(). STRICT requires every stage to return 0
    // (mysqldump|pigz, pg_dump|pigz). TAR_TOLERANT allows the first stage to
    // return 1 (tar's "file changed during archive" warning) while still
    // requiring the compressor stage to return 0.
    private const PIPELINE_STRICT = 'strict';
    private const PIPELINE_TAR_TOLERANT = 'tar_tolerant';

    /**
     * Run a shell pipeline (`a | b > out`) and surface partial-stage failures.
     * Uses proc_open with the argv-array form, so the outer invocation is not
     * shell-parsed — only the script body passed to `bash -c` is, which lets
     * us use pipefail / PIPESTATUS to detect mid-pipeline failures that bare
     * exec() would have swallowed. Combined stderr is captured to a temp file
     * and surfaced via &$stderrLines.
     */
    private static function runPipeline($pipelineCmd, $mode, array &$stderrLines) {
        $stderrTmp = tempnam(sys_get_temp_dir(), 'whp-bk-stderr-');

        if ($mode === self::PIPELINE_TAR_TOLERANT) {
            // Inspect PIPESTATUS instead of relying on pipefail, so tar=1
            // (warning) + downstream=0 is treated as success.
            $script = $pipelineCmd
                . '; rcs=("${PIPESTATUS[@]}");'
                . ' first=${rcs[0]};'
                . ' last=${rcs[$((${#rcs[@]}-1))]};'
                . ' if [ "$first" -gt 1 ] || [ "$last" -ne 0 ]; then exit 1; fi';
        } else {
            $script = 'set -o pipefail; ' . $pipelineCmd;
        }

        $descriptors = [
            0 => ['file', '/dev/null', 'r'],
            1 => ['file', '/dev/null', 'w'],
            2 => ['file', $stderrTmp, 'w'],
        ];
        // Retry proc_open: fork() can return ENOMEM transiently under host-wide
        // commit-accounting pressure (other containers OOM-looping), and the
        // failure often resolves within tens of seconds once the kernel reaps
        // the offending process. Without a generous retry, one such moment
        // permanently fails an otherwise-healthy backup.
        //
        // Sizing: backoff goes up to ~68s (sum of delays below) per call. That
        // covers most in-cycle fork-starvation transients. Sustained pressure
        // (>1 min) flows up to the outer retry loop in backup-worker.php which
        // defers the backup row back to pending for the next 1-minute tick;
        // see scheduleCreationRetry() + the pre-flight host-pressure gate.
        $proc = false;
        $delaysUs = [
            100000,    // 100 ms
            500000,    // 500 ms
            1000000,   //   1 s
            2000000,   //   2 s
            5000000,   //   5 s
            10000000,  //  10 s
            20000000,  //  20 s
            30000000,  //  30 s
        ];
        $lastPhpErr = null;
        for ($attempt = 0; $attempt <= count($delaysUs); $attempt++) {
            // Clear last error so we can attribute any failure here to this call.
            error_clear_last();
            $proc = @proc_open(['/bin/bash', '-c', $script], $descriptors, $pipes);
            if (is_resource($proc)) {
                if ($attempt > 0) {
                    self::backup_log("runPipeline: proc_open recovered on attempt " . ($attempt + 1));
                }
                break;
            }
            // Capture the actual PHP-level reason — descriptor-open failure
            // (open_basedir, EPERM), ENOMEM from fork, etc. The 2026-06-01
            // incident hid an open_basedir block on /dev/null behind a
            // generic "proc_open failed to spawn bash" message because
            // cron eats stderr; never make that mistake again.
            $lastPhpErr = error_get_last();
            if ($attempt < count($delaysUs)) {
                usleep($delaysUs[$attempt]);
            }
        }
        if (!is_resource($proc)) {
            @unlink($stderrTmp);
            $reason = $lastPhpErr && !empty($lastPhpErr['message'])
                ? $lastPhpErr['message']
                : 'unknown reason (error_get_last empty)';
            $stderrLines = [
                'proc_open failed to spawn bash after ' . (count($delaysUs) + 1) . ' attempts: ' . $reason,
            ];
            self::backup_log("runPipeline: proc_open exhausted retries — " . $reason);
            return -1;
        }
        $returnCode = proc_close($proc);

        $stderrText = @file_get_contents($stderrTmp);
        @unlink($stderrTmp);
        $stderrLines = ($stderrText === false || $stderrText === '')
            ? []
            : explode("\n", rtrim($stderrText, "\n"));

        return $returnCode;
    }

    // Returns the active compressor command (no I/O redirection). Used as the
    // tail stage of every backup pipeline so threading/level live in one place.
    private function compressorCmd() {
        static $pigzAvailable = null;
        if ($pigzAvailable === null) {
            $pigzAvailable = (bool) shell_exec('command -v pigz 2>/dev/null');
        }
        return $pigzAvailable
            ? "ionice -c3 pigz -p {$this->pigzThreads} -6"
            : "ionice -c3 gzip -6";
    }
    
    public function processBackup($backupId) {
        // Set generous time limit for backup operations (30 minutes)
        set_time_limit(1800);

        $history = new BackupHistory($this->db, $backupId);
        $data = $history->getData();

        if ($data['status'] !== 'pending' && $data['status'] !== 'claimed') {
            throw new Exception("Backup is not in pending or claimed status, current status: " . $data['status']);
        }

        self::backup_log("Starting backup #{$backupId}: type={$data['backup_type']}, user={$data['user']}, name={$data['backup_name']}");

        // Update status to running and creation_status to creating
        $history->updateStatus('running');
        $this->updateCreationStatus($backupId, 'creating');

        try {
            // Phase 1: Create backup locally
            $localPath = $this->createBackupLocally($data);

            // Phase 2: Queue for upload
            $this->queueForUpload($backupId, $localPath);

            // Mark creation as completed (backup is now ready for upload)
            $this->updateCreationStatus($backupId, 'created');
            $history->updateStatus('completed'); // This means creation completed, not full upload
            self::backup_log("Backup #{$backupId} created successfully at {$localPath}");

        } catch (Exception $e) {
            // Log the failure before updating status
            self::backup_log("Backup #{$backupId} FAILED: " . $e->getMessage());

            // Update status to failed
            $this->updateCreationStatus($backupId, 'failed');
            $history->updateStatus('failed', $e->getMessage());

            // Clean up any partial local backup
            if (isset($localPath) && file_exists($localPath)) {
                $this->localStorage->markFailed($localPath);
            }

            throw $e;
        }
    }

    /**
     * Create backup locally without uploading
     */
    private function createBackupLocally($data) {
        // Estimate backup size for space allocation
        $estimatedSize = $this->estimateBackupSize($data);

        // Allocate local storage space
        $localPath = $this->localStorage->allocateSpace($data['backup_name'], $estimatedSize);

        // Update database with local path
        $stmt = $this->db->prepare("UPDATE backup_history SET local_path = ? WHERE id = ?");
        $stmt->execute([$localPath, $data['id']]);

        try {
            switch ($data['backup_type']) {
                case 'site':
                    $this->createSiteBackup($data, $localPath);
                    break;
                case 'userfiles':
                    $this->createUserfilesBackup($data, $localPath);
                    break;
                case 'database':
                    $this->createDatabaseBackup($data, $localPath);
                    break;
                case 'postgres_database':
                    $this->createPostgresDatabaseBackup($data, $localPath);
                    break;
                case 'wordpress':
                    $this->createWordPressBackup($data, $localPath);
                    break;
                default:
                    throw new Exception("Unknown backup type: " . $data['backup_type']);
            }

            // Get actual file size and mark as created
            $actualSize = filesize($localPath);
            $readyPath = $this->localStorage->markCreated($localPath, $actualSize);

            // Update database with final path and size
            $stmt = $this->db->prepare("
                UPDATE backup_history
                SET local_path = ?, local_size = ?, backup_size = ?
                WHERE id = ?
            ");
            $stmt->execute([$readyPath, $actualSize, $actualSize, $data['id']]);

            return $readyPath;

        } catch (Exception $e) {
            // Clean up failed backup
            $this->localStorage->markFailed($localPath);
            throw $e;
        }
    }

    /**
     * Queue backup for upload
     */
    private function queueForUpload($backupId, $localPath) {
        // Add to upload queue
        $stmt = $this->db->prepare("
            INSERT INTO backup_upload_queue (backup_id, priority, status)
            VALUES (?, 5, 'queued')
            ON DUPLICATE KEY UPDATE
            status = 'queued', retry_count = 0, error_message = NULL
        ");
        $stmt->execute([$backupId]);

        // Update backup status
        $stmt = $this->db->prepare("
            UPDATE backup_history
            SET upload_status = 'pending'
            WHERE id = ?
        ");
        $stmt->execute([$backupId]);

        self::backup_log("Backup #{$backupId} created locally ({$localPath}) and queued for upload");
    }

    /**
     * Update creation status
     */
    private function updateCreationStatus($backupId, $status) {
        $stmt = $this->db->prepare("
            UPDATE backup_history
            SET creation_status = ?
            WHERE id = ?
        ");
        $stmt->execute([$status, $backupId]);
    }

    /**
     * Estimate backup size for space allocation
     */
    private function estimateBackupSize($data) {
        $metadata = is_array($data['metadata']) ? $data['metadata'] : json_decode($data['metadata'], true);

        switch ($data['backup_type']) {
            case 'site':
            case 'wordpress':
                // Estimate based on directory size
                $sourcePath = $metadata['source_path'] ?? null;
                if ($sourcePath && is_dir($sourcePath)) {
                    return $this->getDirectorySize($sourcePath);
                }
                break;

            case 'userfiles':
                $sourcePath = $metadata['source_path'] ?? null;
                if ($sourcePath && is_dir($sourcePath)) {
                    return $this->getDirectorySize($sourcePath);
                }
                break;

            case 'database':
                // Estimate database size
                $database = $metadata['database'] ?? '';
                return $this->estimateDatabaseSize($database);

            case 'postgres_database':
                // pg_dump output size is hard to predict cheaply; reserve 200MB
                // up front. Worker still uses the actual file size after dump.
                return 200 * 1024 * 1024;
        }

        // Default estimate: 100MB
        return 100 * 1024 * 1024;
    }

    private function getDirectorySize($directory) {
        $size = 0;
        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS)
            );

            foreach ($iterator as $file) {
                if ($file->isFile()) {
                    $size += $file->getSize();
                }
            }
        } catch (Exception $e) {
            // If we can't read the directory, use a default estimate
            error_log("Failed to calculate directory size for {$directory}: " . $e->getMessage());
            $size = 50 * 1024 * 1024; // 50MB default
        }

        return $size;
    }

    private function estimateDatabaseSize($database) {
        try {
            $stmt = $this->db->prepare("
                SELECT ROUND(SUM(data_length + index_length), 0) as size
                FROM information_schema.tables
                WHERE table_schema = ?
            ");
            $stmt->execute([$database]);
            $result = $stmt->fetch();

            return $result['size'] ?? (10 * 1024 * 1024); // 10MB default
        } catch (Exception $e) {
            error_log("Failed to estimate database size for {$database}: " . $e->getMessage());
            return 10 * 1024 * 1024; // 10MB default
        }
    }
    
    private function createSiteBackup($data, $localPath) {
        $metadata = is_array($data['metadata']) ? $data['metadata'] : json_decode($data['metadata'], true);
        $sourcePath = $metadata['source_path'];
        $user = $data['user'];
        $domain = $metadata['domain'];

        // Create archive directly to local storage
        $this->createSiteArchive($sourcePath, $localPath, $user, $domain);
    }
    
    private function createUserfilesBackup($data, &$localPath) {
        $metadata = is_array($data['metadata']) ? $data['metadata'] : json_decode($data['metadata'], true);
        $sourcePath = $metadata['source_path'];
        $user = $data['user'];

        // Add .zip extension to localPath if not present
        if (substr($localPath, -4) !== '.zip') {
            $localPath = $localPath . '.zip';
            // Update database with actual path including .zip extension
            $stmt = $this->db->prepare("UPDATE backup_history SET local_path = ? WHERE id = ?");
            $stmt->execute([$localPath, $data['id']]);
        }

        // Create archive directly to local storage (use zip for better compatibility)
        $this->createUserfilesArchiveZip($sourcePath, $localPath, $user);
    }
    
    private function createDatabaseBackup($data, $localPath) {
        $metadata = is_array($data['metadata']) ? $data['metadata'] : json_decode($data['metadata'], true);
        $database = $metadata['database'];
        $user = $data['user'];

        // Create SQL dump directly to local storage
        $this->createDatabaseDump($database, $localPath);
    }

    private function createPostgresDatabaseBackup($data, $localPath) {
        $metadata = is_array($data['metadata']) ? $data['metadata'] : json_decode($data['metadata'], true);
        $database = $metadata['database'];
        if (empty($database)) {
            throw new Exception("PostgreSQL database name missing from backup metadata");
        }
        $this->createPostgresDatabaseDump($database, $localPath);
    }

    /**
     * Dump a PostgreSQL database to a gzipped file. Mirrors createDatabaseDump
     * but uses pg_dump with the root postgres credential from POSTGRES_PASS.
     * The backup worker already runs as the WHP service account, so using the
     * root credential is consistent with how mysqldump is invoked elsewhere.
     */
    private function createPostgresDatabaseDump($database, $tempFile) {
        if (!preg_match('/^[a-zA-Z0-9_-]+$/', $database)) {
            throw new Exception("Invalid PostgreSQL database name format");
        }

        $host = '127.0.0.1';
        $port = 5432;
        $rootUser = 'postgres';
        $rootPass = defined('POSTGRES_PASS') ? trim(POSTGRES_PASS) : '';
        if ($rootPass === '') {
            throw new Exception("POSTGRES_PASS is not defined; cannot dump PostgreSQL database");
        }

        // Confirm the database exists before invoking pg_dump.
        require_once('/docker/whp/web/libs/pgsqlmgmt.php');
        $pg = new \pgsqlmgmt();
        $pdo = $pg->getPostgreSQLConnection();
        if (!$pdo) {
            throw new Exception("Failed to connect to PostgreSQL");
        }
        $existsStmt = $pdo->prepare("SELECT 1 FROM pg_database WHERE datname = ?");
        $existsStmt->execute([$database]);
        if (!$existsStmt->fetch()) {
            throw new Exception("PostgreSQL database '$database' does not exist");
        }

        // Stream pg_dump straight into the compressor. PGPASSWORD env keeps the
        // password off argv (`ps` listings). pg_dump writes to stdout when -f
        // is omitted, so we just pipe it directly.
        $envPrefix = "PGPASSWORD=" . escapeshellarg($rootPass) . " ";
        $pipeline = $envPrefix . "ionice -c3 pg_dump"
            . " -h " . escapeshellarg($host)
            . " -p " . escapeshellarg($port)
            . " -U " . escapeshellarg($rootUser)
            . " --no-owner --no-acl --clean --if-exists"
            . " " . escapeshellarg($database)
            . " | " . $this->compressorCmd()
            . " > " . escapeshellarg($tempFile);

        $stderr = [];
        $returnCode = self::runPipeline($pipeline, self::PIPELINE_STRICT, $stderr);

        if ($returnCode !== 0) {
            if (file_exists($tempFile)) {
                @unlink($tempFile);
            }
            throw new Exception("pg_dump failed: " . implode("\n", $stderr));
        }
        if (!file_exists($tempFile) || filesize($tempFile) === 0) {
            throw new Exception("Compressed pg_dump output is empty");
        }
    }

    private function createWordPressBackup($data, $localPath) {
        $metadata = is_array($data['metadata']) ? $data['metadata'] : json_decode($data['metadata'], true);
        $sourcePath = $metadata['source_path'];
        $database = $metadata['database'] ?? null;
        $domain = $metadata['domain'];
        $user = $data['user'];

        // Validate user and domain for security
        if (!preg_match('/^[a-zA-Z0-9_-]+$/', $user)) {
            throw new Exception("Invalid user format");
        }
        if (!preg_match('/^[a-zA-Z0-9.-]+$/', $domain)) {
            throw new Exception("Invalid domain format");
        }

        // Validate source path is within expected directory structure
        $expectedBasePath = "/docker/users/$user/";
        $realSourcePath = realpath($sourcePath);
        if (!$realSourcePath || strpos($realSourcePath, $expectedBasePath) !== 0) {
            throw new Exception("Invalid source path");
        }

        // Create combined WordPress archive directly to local storage
        $this->createWordPressArchive($sourcePath, $database, $localPath, $user, $domain);
    }
    
    private function createSiteArchive($sourcePath, $tempFile, $user, $domain) {
        if (!is_dir($sourcePath)) {
            throw new Exception("Site directory not found: $sourcePath");
        }
        
        $userDir = dirname($sourcePath);
        $userName = basename($userDir);
        $domainName = basename($sourcePath);
        
        // Exclude log directories and suppress file-changed warnings
        $tarExcludes = "--exclude='*/logs' --warning=no-file-changed";

        // Stream tar -> compressor. No intermediate uncompressed .tar file.
        $pipeline = "cd " . escapeshellarg(dirname($userDir))
            . " && ionice -c3 tar {$tarExcludes} -cf - "
            . escapeshellarg($userName . "/" . $domainName)
            . " | " . $this->compressorCmd()
            . " > " . escapeshellarg($tempFile);

        $stderr = [];
        $returnCode = self::runPipeline($pipeline, self::PIPELINE_TAR_TOLERANT, $stderr);

        if ($returnCode !== 0) {
            throw new Exception("Failed to create site archive: " . implode("\n", $stderr));
        }

        if (!file_exists($tempFile) || filesize($tempFile) === 0) {
            throw new Exception("Site archive file is empty or doesn't exist");
        }
    }

    private function createUserfilesArchive($sourcePath, $tempFile, $user) {
        if (!is_dir($sourcePath)) {
            throw new Exception("Userfiles directory not found: $sourcePath");
        }

        $userDir = dirname($sourcePath);
        $userName = basename($userDir);

        // Exclude log directories and suppress file-changed warnings
        $tarExcludes = "--exclude='*/logs' --warning=no-file-changed";

        // Stream tar -> compressor. No intermediate uncompressed .tar file.
        $pipeline = "cd " . escapeshellarg(dirname($userDir))
            . " && ionice -c3 tar {$tarExcludes} -cf - "
            . escapeshellarg($userName . "/userfiles")
            . " | " . $this->compressorCmd()
            . " > " . escapeshellarg($tempFile);

        $stderr = [];
        $returnCode = self::runPipeline($pipeline, self::PIPELINE_TAR_TOLERANT, $stderr);

        if ($returnCode !== 0) {
            throw new Exception("Failed to create userfiles archive: " . implode("\n", $stderr));
        }

        if (!file_exists($tempFile) || filesize($tempFile) === 0) {
            throw new Exception("Userfiles archive file is empty or doesn't exist");
        }
    }
    
    private function createUserfilesArchiveZip($sourcePath, $tempFile, $user) {
        if (!is_dir($sourcePath)) {
            throw new Exception("Userfiles directory not found: $sourcePath");
        }

        $userDir = dirname($sourcePath);
        $userName = basename($userDir);

        // Create zip archive, excluding log directories.
        // ionice -c3 keeps reads in the idle I/O class so live traffic isn't starved.
        $command = "cd " . escapeshellarg(dirname($userDir)) . " && ionice -c3 zip -r " . escapeshellarg($tempFile) . " " . escapeshellarg($userName . "/userfiles") . " -x " . escapeshellarg("*/logs/*") . " 2>&1";

        exec($command, $output, $returnCode);

        if ($returnCode !== 0) {
            throw new Exception("Failed to create userfiles archive: " . implode("\n", $output));
        }

        if (!file_exists($tempFile) || filesize($tempFile) === 0) {
            throw new Exception("Userfiles archive file is empty or doesn't exist");
        }

    }
    
    private function createDatabaseDump($database, $tempFile) {
        // Validate database name for security (prevent SQL injection and command injection)
        // Allow alphanumeric characters, underscores, and hyphens (common in WordPress database names)
        if (!preg_match('/^[a-zA-Z0-9_-]+$/', $database)) {
            throw new Exception("Invalid database name format");
        }

        // Get database credentials
        $mysql = new \mysqlmgmt();
        $host = '127.0.0.1';
        $port = 3306;
        $rootUser = 'root';
        $rootPass = defined('MYSQL_PASS') ? trim(MYSQL_PASS) : '';
        
        // First, test if the database exists
        try {
            $testStmt = $this->db->prepare("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?");
            $testStmt->execute([$database]);
            if (!$testStmt->fetch()) {
                throw new Exception("Database '$database' does not exist");
            }
        } catch (Exception $e) {
            throw new Exception("Failed to verify database existence: " . $e->getMessage());
        }
        
        // Stream mysqldump straight into the compressor and out to disk. No
        // intermediate uncompressed .sql file — halves disk I/O on backup-size
        // sites. --quick makes mysqldump fetch row-by-row so the pipe doesn't
        // backpressure on its internal buffer.
        $passwordArg = $rootPass ? "--password=" . escapeshellarg($rootPass) : "";

        $pipeline = "ionice -c3 mysqldump -h " . escapeshellarg($host)
            . " -P " . escapeshellarg($port)
            . " -u " . escapeshellarg($rootUser)
            . " " . $passwordArg
            . " --single-transaction --quick --routines --triggers --events"
            . " --add-drop-database --create-options --complete-insert"
            . " --databases " . escapeshellarg($database)
            . " | " . $this->compressorCmd()
            . " > " . escapeshellarg($tempFile);

        $stderr = [];
        $returnCode = self::runPipeline($pipeline, self::PIPELINE_STRICT, $stderr);

        if ($returnCode !== 0) {
            if (file_exists($tempFile)) {
                @unlink($tempFile);
            }
            throw new Exception("Failed to create database dump: " . implode("\n", $stderr));
        }

        if (!file_exists($tempFile) || filesize($tempFile) === 0) {
            throw new Exception("Compressed database dump file is empty or doesn't exist");
        }
    }

    private function createWordPressArchive($sourcePath, $database, $tempFile, $user, $domain) {
        if (!is_dir($sourcePath)) {
            throw new Exception("WordPress site directory not found: $sourcePath");
        }

        // Create temporary directory for organizing the backup content
        // Use random_bytes for cryptographically secure temporary directory name
        $randomSuffix = bin2hex(random_bytes(16));
        $tempWorkDir = $this->tempDir . '/wp_backup_' . $randomSuffix;
        if (!mkdir($tempWorkDir, 0700, true)) { // More restrictive permissions
            throw new Exception("Failed to create temporary work directory");
        }

        try {
            // Step 1: Create site files archive
            $siteArchivePath = $tempWorkDir . '/site.tar.gz';
            $this->createSiteArchiveForWordPress($sourcePath, $siteArchivePath, $user, $domain);

            // Step 2: Create database dump if database exists
            $dbDumpPath = null;
            if (!empty($database)) {
                $dbDumpPath = $tempWorkDir . '/database.sql.gz';
                $this->createDatabaseDump($database, $dbDumpPath);
            }

            // Step 3: Create manifest file with backup information
            $manifestPath = $tempWorkDir . '/backup-manifest.json';
            $manifest = [
                'backup_type' => 'wordpress',
                'domain' => $domain,
                'user' => $user,
                'created_at' => date('Y-m-d H:i:s'),
                'files' => [
                    'site' => 'site.tar.gz',
                    'database' => $database ? 'database.sql.gz' : null
                ],
                'database_name' => $database
            ];
            file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT));

            // Step 4: Create final combined archive
            $filesToArchive = ['backup-manifest.json', 'site.tar.gz'];
            if ($dbDumpPath && file_exists($dbDumpPath)) {
                $filesToArchive[] = 'database.sql.gz';
            }

            // Use tar with gzip compression.
            // ionice -c3 keeps the bundle step in the idle I/O class. Inputs are small
            // pre-built parts (already-compressed site/db archives + manifest), so
            // single-stream gzip here is fine — pigz wouldn't help.
            $filesArg = implode(' ', array_map('escapeshellarg', $filesToArchive));
            $command = "cd " . escapeshellarg($tempWorkDir) . " && ionice -c3 tar -czf " . escapeshellarg($tempFile) . " " . $filesArg . " 2>&1";

            exec($command, $output, $returnCode);

            if ($returnCode !== 0) {
                throw new Exception("Failed to create WordPress archive: " . implode("\n", $output));
            }

            if (!file_exists($tempFile) || filesize($tempFile) === 0) {
                throw new Exception("WordPress archive file is empty or doesn't exist");
            }

        } finally {
            // Clean up temporary work directory
            if (is_dir($tempWorkDir)) {
                $this->removeDirectory($tempWorkDir);
            }
        }
    }

    private function createSiteArchiveForWordPress($sourcePath, $tempFile, $user, $domain) {
        // Mirrors createSiteArchive but writes the archive at the path supplied
        // by the WordPress-combined flow (so the combined archive can wrap it).
        $userDir = dirname($sourcePath);
        $userName = basename($userDir);
        $domainName = basename($sourcePath);

        // Exclude log directories and suppress file-changed warnings
        $tarExcludes = "--exclude='*/logs' --warning=no-file-changed";

        // Stream tar -> compressor. No intermediate uncompressed .tar file.
        $pipeline = "cd " . escapeshellarg(dirname($userDir))
            . " && ionice -c3 tar {$tarExcludes} -cf - "
            . escapeshellarg($userName . "/" . $domainName)
            . " | " . $this->compressorCmd()
            . " > " . escapeshellarg($tempFile);

        $stderr = [];
        $returnCode = self::runPipeline($pipeline, self::PIPELINE_TAR_TOLERANT, $stderr);

        if ($returnCode !== 0) {
            throw new Exception("Failed to create site archive: " . implode("\n", $stderr));
        }

        if (!file_exists($tempFile) || filesize($tempFile) === 0) {
            throw new Exception("Site archive file is empty or doesn't exist");
        }
    }

    private function removeDirectory($dir) {
        if (!is_dir($dir)) {
            return;
        }

        $files = array_diff(scandir($dir), ['.', '..']);
        foreach ($files as $file) {
            $path = $dir . '/' . $file;
            if (is_dir($path)) {
                $this->removeDirectory($path);
            } else {
                unlink($path);
            }
        }
        rmdir($dir);
    }

    // uploadToStorage method removed - uploads now handled by separate upload worker
}
