<?php

/**
 * cPanel Backup Importer for WHP
 * 
 * Handles the import of cPanel backup tarballs into WHP system
 * Extracts domains, files, databases, and users from cPanel backups
 */
class CpanelBackupImporter {
    
    private $pdo;
    private $backupPath;
    private $extractPath;
    private $backupRoot;
    private $username;
    private $importLog = [];
    private $siteGroups = [];
    private $mysqlMgmt;
    private $powerDNS;
    private $dnsSummary = ['original_mx' => []];
    private $siteWarnings = [];

    /** @var string|null per-import ID used to derive the per-import summary encryption key */
    private $importId = null;

    /** @var array running collection of mailbox/forwarder import outcomes for the summary JSON */
    private $emailSummary = ['mailboxes' => [], 'forwarders' => [], 'warnings' => []];

    /**
     * @var array list of databases successfully imported from cpmove. Each entry is
     *            ['original_name' => string, 'whp_name' => string, 'size_bytes' => ?int].
     *            Populated by importDatabase() on success; consumed by getImportedDatabases().
     */
    private $importedDatabases = [];

    /**
     * Hostnames that point at the old cPanel admin UIs and serve no purpose on WHP.
     * We skip these when replicating cpmove DNS records into the new PowerDNS zone.
     */
    private const CPANEL_INTERNAL_HOSTNAMES = [
        'cpanel', 'whm', 'webdisk', 'webmail', 'cpcalendars', 'cpcontacts',
        'cpanel.files', 'whm.files', 'webdisk.files', 'webmail.files',
        'cpcalendars.files', 'cpcontacts.files',
    ];

    /**
     * cPanel EasyApache PHP package names → version strings used in
     * whp.container_types.name (e.g. "PHP 8.2").
     */
    private const PHP_VERSION_MAP = [
        'ea-php74' => '7.4',
        'ea-php80' => '8.0',
        'ea-php81' => '8.1',
        'ea-php82' => '8.2',
        'ea-php83' => '8.3',
        'ea-php84' => '8.4',
    ];

    public function __construct($pdo, $username) {
        $this->pdo = $pdo;
        $this->username = $username;
        $this->extractPath = "/tmp/cpanel_import_" . uniqid();
    }

    /**
     * Bind the worker's import_id to this importer so per-mailbox temp passwords
     * can be encrypted with a per-import key in the summary JSON.
     */
    public function setImportId(string $importId): void {
        $this->importId = $importId;
    }


    /**
     * Import a cPanel backup tarball
     * 
     * @param string $backupFilePath Path to the cPanel backup .tar.gz file
     * @return array Import results with success/failure status
     */
    public function importBackup($backupFilePath) {
        try {
            $this->log("Starting cPanel backup import for user: " . $this->username);
            $this->reportProgress(15, 'starting');

            // Move backup file from userfiles to temp directory to avoid storage quota
            $this->backupPath = $this->moveBackupToTemp($backupFilePath);

            // Validate backup file before processing
            $this->validateBackupFile();

            // Extract the backup tarball
            $this->extractBackup();
            $this->reportProgress(25, 'extracted');

            // Validate extracted backup structure
            $this->validateBackupStructure();

            // Parse backup structure
            $backupData = $this->parseBackupStructure();
            $this->reportProgress(35, 'parsed');

            // Validate parsed data
            $this->validateBackupData($backupData);

            // Import domains and subdomains
            $domains = $this->importDomains($backupData);

            // Create WHP sites for domains
            $sites = $this->createSites($domains);
            $this->reportProgress(45, 'sites_created');

            // Import website files
            $this->importFiles($domains);
            $this->reportProgress(60, 'files_imported');

            // Import databases
            $this->importDatabases($backupData);
            $this->reportProgress(75, 'databases_imported');

            // Import MySQL users
            $this->importMysqlUsers($backupData);
            $this->reportProgress(80, 'mysql_users_imported');

            // Create DNS records for imported domains
            $this->createDnsRecords($domains);
            $this->reportProgress(85, 'dns_imported');

            // NOTE: cleanup() intentionally NOT called here on success — the
            // worker runs an email-import phase after this method returns
            // that needs the extracted files (homedir/etc/<domain>/passwd,
            // va/<domain>) still on disk. The worker calls cleanup() once
            // it's done with the email phase + summary write.
            
            return [
                'success' => true,
                'domains_imported' => count($domains),
                'sites_created' => count($sites),
                'databases_imported' => count($backupData['mysql_databases'] ?? []),
                'log' => $this->importLog
            ];
            
        } catch (Exception $e) {
            $this->log("Import failed: " . $e->getMessage(), 'error');
            $this->cleanup();
            
            return [
                'success' => false,
                'error' => $e->getMessage(),
                'log' => $this->importLog
            ];
        }
    }
    
    /**
     * Move backup file from userfiles to temp directory
     * This prevents the backup file from counting against user storage quota during processing
     */
    private function moveBackupToTemp($originalPath) {
        // Create temp directory
        $tempDir = "/tmp/cpanel_processing_" . uniqid();
        if (!mkdir($tempDir, 0755, true)) {
            throw new Exception("Failed to create temporary processing directory");
        }

        $tempPath = $tempDir . "/" . basename($originalPath);

        // Hard-link (or copy fallback) the file into the temp directory.
        // Originally this used rename() which destroyed the operator's archive
        // on any subsequent failure, blocking retries. Using link() preserves
        // the source while costing zero disk on the same filesystem; if the
        // source and tempdir live on different mounts (link() returns false),
        // fall back to a copy.
        if (!@link($originalPath, $tempPath)) {
            if (!copy($originalPath, $tempPath)) {
                throw new Exception("Failed to stage backup file to temporary directory");
            }
            $this->log("Copied backup file to temporary directory: $tempPath", 'info');
        } else {
            $this->log("Hard-linked backup file to temporary directory: $tempPath", 'info');
        }
        return $tempPath;
    }
    
    /**
     * Validate backup file before processing
     */
    private function validateBackupFile() {
        if (!file_exists($this->backupPath)) {
            throw new Exception("Backup file not found: " . $this->backupPath);
        }
        
        if (!is_readable($this->backupPath)) {
            throw new Exception("Backup file is not readable");
        }
        
        // Check file size. cPanel cpmove archives for production customers
        // routinely exceed several GB (Maildirs + databases + assets). Cap at
        // 50 GB; require ~3x free disk in /tmp at extraction time (handled at
        // extract step, not here) so we don't fill the worker volume.
        $fileSize = filesize($this->backupPath);
        if ($fileSize === false) {
            throw new Exception("Cannot determine backup file size");
        }

        if ($fileSize > 50 * 1024 * 1024 * 1024) {
            throw new Exception("Backup file too large (max 50GB allowed); contact support for larger imports");
        }

        if ($fileSize < 1024) { // At least 1KB
            throw new Exception("Backup file appears to be empty or corrupted");
        }
        
        // Validate file type by checking magic bytes
        $handle = fopen($this->backupPath, 'rb');
        if ($handle === false) {
            throw new Exception("Cannot open backup file for validation");
        }
        
        $header = fread($handle, 10);
        fclose($handle);
        
        // Check for gzip magic bytes (1f 8b) or tar signature
        if (!($header[0] === "\x1f" && $header[1] === "\x8b")) {
            throw new Exception("Invalid backup file format (not a valid .tar.gz file)");
        }
        
        $this->log("Backup file validation passed - Size: " . number_format($fileSize / 1024 / 1024, 2) . " MB");
    }
    
    /**
     * Validate extracted backup structure
     */
    private function validateBackupStructure() {
        if (!is_dir($this->extractPath)) {
            throw new Exception("Extraction directory not found");
        }
        
        $dirs = glob($this->extractPath . "/*", GLOB_ONLYDIR);
        if (empty($dirs)) {
            throw new Exception("No directories found in extracted backup - invalid backup structure");
        }
        
        $mainDir = $dirs[0];
        
        // Check for essential cPanel backup directories/files
        $requiredPaths = [
            'homedir',     // Website files
            'userdata'     // Domain configurations
        ];
        
        $foundPaths = [];
        foreach ($requiredPaths as $path) {
            $fullPath = $mainDir . '/' . $path;
            if (file_exists($fullPath)) {
                $foundPaths[] = $path;
            }
        }
        
        if (empty($foundPaths)) {
            throw new Exception("Invalid cPanel backup - missing essential directories (homedir, userdata)");
        }
        
        $this->log("Backup structure validation passed - Found: " . implode(', ', $foundPaths));
    }
    
    /**
     * Validate parsed backup data
     */
    private function validateBackupData($backupData) {
        if (empty($backupData)) {
            throw new Exception("No data extracted from backup");
        }
        
        // Check if we have at least some domains or files to import
        $domainCount = count($backupData['domains'] ?? []);
        $addonCount = count($backupData['addons'] ?? []);
        $subdomainCount = count($backupData['subdomains'] ?? []);
        
        if ($domainCount === 0 && $addonCount === 0 && $subdomainCount === 0) {
            throw new Exception("No domains found in backup - nothing to import");
        }
        
        // Validate domain names
        foreach ($backupData['domains'] ?? [] as $domain => $config) {
            if (!$this->isValidDomain($domain)) {
                $this->log("Invalid domain name found: " . $domain, 'warning');
            }
        }
        
        // Check if homedir exists and has content
        if (isset($backupData['homedir']) && is_dir($backupData['homedir'])) {
            $homedirSize = $this->getDirectorySize($backupData['homedir']);
            if ($homedirSize === 0) {
                $this->log("Home directory appears to be empty", 'warning');
            } else {
                $this->log("Home directory size: " . number_format($homedirSize / 1024 / 1024, 2) . " MB");
            }
        }
        
        $totalDomains = $domainCount + $addonCount + $subdomainCount;
        $this->log("Backup data validation passed - Total domains: " . $totalDomains);
    }
    
    /**
     * Extract the cPanel backup tarball
     */
    private function extractBackup() {
        if (!file_exists($this->backupPath)) {
            throw new Exception("Backup file not found: " . $this->backupPath);
        }
        
        // Create extraction directory
        if (!mkdir($this->extractPath, 0755, true)) {
            throw new Exception("Failed to create extraction directory");
        }
        
        $this->log("Extracting backup to: " . $this->extractPath);
        
        // Extract using tar command
        $command = "cd " . escapeshellarg($this->extractPath) . " && tar -xzf " . escapeshellarg($this->backupPath);
        $output = [];
        $returnCode = 0;
        
        exec($command, $output, $returnCode);
        
        if ($returnCode !== 0) {
            throw new Exception("Failed to extract backup: " . implode("\n", $output));
        }
        
        $this->log("Backup extracted successfully");
    }
    
    /**
     * Parse the extracted backup structure
     */
    private function parseBackupStructure() {
        $backupData = [];
        
        // Find the main backup directory (should be username directory)
        $dirs = glob($this->extractPath . "/*", GLOB_ONLYDIR);
        if (empty($dirs)) {
            throw new Exception("No directories found in backup");
        }
        
        $mainDir = $dirs[0]; // Usually the cPanel username directory
        $this->backupRoot = $mainDir;
        $this->log("Found main backup directory: " . basename($mainDir));
        
        // Parse userdata for domain information
        $backupData['domains'] = $this->parseUserdata($mainDir . '/userdata');
        
        // Parse addon domains
        $backupData['addons'] = $this->parseAddons($mainDir . '/addons');
        
        // Parse subdomains
        $backupData['subdomains'] = $this->parseSubdomains($mainDir . '/sds');
        
        // Parse MySQL information
        $backupData['mysql_grants'] = $this->parseMysqlGrants($mainDir . '/mysql.sql');
        $backupData['mysql_databases'] = $this->getMysqlDatabases($mainDir . '/mysql');
        
        // Set file paths
        $backupData['homedir'] = $mainDir . '/homedir';
        $backupData['ssl_certs'] = $mainDir . '/sslcerts';
        $backupData['ssl_keys'] = $mainDir . '/sslkeys';
        
        return $backupData;
    }
    
    /**
     * Parse userdata directory for domain configurations
     * Maps domains to their document roots and creates site groups
     */
    private function parseUserdata($userdataDir) {
        $domains = [];
        $siteGroups = []; // Group domains by document root
        
        if (!is_dir($userdataDir)) {
            $this->log("No userdata directory found", 'warning');
            return $domains;
        }
        
        foreach (glob($userdataDir . "/*") as $file) {
            if (is_file($file)) {
                $domain = basename($file);

                // Skip cPanel bookkeeping files that aren't per-domain userdata
                $skipBasenames = ['main', 'scope', 'cache.json'];
                if (in_array($domain, $skipBasenames, true)) {
                    continue;
                }
                if (strpos($domain, '_SSL') !== false) {
                    $this->log("Skipping SSL tracking domain: " . $domain, 'info');
                    continue;
                }
                if (str_ends_with($domain, '.php-fpm.yaml')
                    || str_ends_with($domain, '.php-fpm.yaml.transferred')
                    || str_ends_with($domain, '.transferred')
                    || str_ends_with($domain, '.json')) {
                    continue;
                }

                $config = $this->parseUserdataFile($file);
                if ($config) {
                    $domains[$domain] = $config;
                    
                    // Group domains by document root for site creation
                    $docRoot = $config['document_root'] ?? '/public_html';
                    if (!isset($siteGroups[$docRoot])) {
                        $siteGroups[$docRoot] = [
                            'primary_domain' => $domain,
                            'additional_domains' => [],
                            'document_root' => $docRoot,
                            'phpversion' => $config['phpversion'] ?? null,
                        ];
                    } else {
                        $siteGroups[$docRoot]['additional_domains'][] = $domain;
                    }
                }
            }
        }
        
        // Store site groups for later use
        $this->siteGroups = $siteGroups;
        
        $this->log("Found " . count($domains) . " domains in userdata, grouped into " . count($siteGroups) . " sites");
        return $domains;
    }
    
    /**
     * Parse individual userdata file
     */
    private function parseUserdataFile($file) {
        $content = file_get_contents($file);
        if (!$content) return null;
        
        $config = [];
        $lines = explode("\n", $content);
        
        foreach ($lines as $line) {
            $line = trim($line);
            if (empty($line) || strpos($line, ':') === false) continue;
            
            list($key, $value) = explode(':', $line, 2);
            $key = trim($key);
            $value = trim($value);
            
            if ($key === 'documentroot') {
                $config['document_root'] = $value;
            } elseif ($key === 'serveralias') {
                $config['aliases'] = array_filter(explode(' ', $value));
            } elseif ($key === 'ssl') {
                $config['ssl_enabled'] = ($value === '1');
            } elseif ($key === 'phpversion') {
                $config['phpversion'] = $value;
            }
        }
        
        return $config;
    }
    
    /**
     * Parse addon domains file
     */
    private function parseAddons($addonsFile) {
        if (!file_exists($addonsFile)) {
            return [];
        }
        
        $content = file_get_contents($addonsFile);
        $addons = [];
        
        // Parse the addon domains format
        $lines = explode("\n", trim($content));
        foreach ($lines as $line) {
            if (trim($line)) {
                // Format is typically: domain.com=subdomain.maindomain.com
                if (strpos($line, '=') !== false) {
                    list($addon, $subdomain) = explode('=', $line, 2);
                    $addons[trim($addon)] = trim($subdomain);
                }
            }
        }
        
        $this->log("Found " . count($addons) . " addon domains");
        return $addons;
    }
    
    /**
     * Parse subdomains file
     */
    private function parseSubdomains($subdomainsFile) {
        if (!file_exists($subdomainsFile)) {
            return [];
        }
        
        $content = file_get_contents($subdomainsFile);
        $subdomains = [];
        
        $lines = explode("\n", trim($content));
        foreach ($lines as $line) {
            $line = trim($line);
            if ($line) {
                $subdomains[] = $line;
            }
        }
        
        $this->log("Found " . count($subdomains) . " subdomains");
        return $subdomains;
    }
    
    /**
     * Parse MySQL grants file
     */
    private function parseMysqlGrants($grantsFile) {
        if (!file_exists($grantsFile)) {
            return [];
        }
        
        $content = file_get_contents($grantsFile);
        $grants = [];
        
        // Parse GRANT statements to extract database users
        preg_match_all("/GRANT .* TO '([^']+)'@'([^']+)'/", $content, $matches, PREG_SET_ORDER);
        
        foreach ($matches as $match) {
            $username = $match[1];
            $host = $match[2];
            $grants[] = ['username' => $username, 'host' => $host];
        }
        
        $this->log("Found " . count($grants) . " MySQL user grants");
        return $grants;
    }
    
    /**
     * Get MySQL database files
     */
    private function getMysqlDatabases($mysqlDir) {
        $databases = [];
        
        if (!is_dir($mysqlDir)) {
            return $databases;
        }
        
        foreach (glob($mysqlDir . "/*.sql") as $file) {
            $dbName = basename($file, '.sql');
            $databases[$dbName] = $file;
        }
        
        $this->log("Found " . count($databases) . " MySQL databases");
        return $databases;
    }
    
    /**
     * Import domains and create WHP domain entries
     */
    private function importDomains($backupData) {
        $importedDomains = [];
        
        // Import main domains from userdata
        foreach ($backupData['domains'] as $domain => $config) {
            $importedDomains[] = [
                'domain' => $domain,
                'type' => 'primary',
                'config' => $config
            ];
        }
        
        // Import addon domains
        foreach ($backupData['addons'] as $addon => $subdomain) {
            $importedDomains[] = [
                'domain' => $addon,
                'type' => 'addon',
                'subdomain' => $subdomain
            ];
        }
        
        // Import subdomains
        foreach ($backupData['subdomains'] as $subdomain) {
            $importedDomains[] = [
                'domain' => $subdomain,
                'type' => 'subdomain'
            ];
        }
        
        $this->log("Prepared " . count($importedDomains) . " domains for import");
        return $importedDomains;
    }
    
    /**
     * Create WHP sites for imported domains using document root groupings
     */
    private function createSites($domains) {
        $createdSites = [];
        
        // Create sites based on document root groupings
        foreach ($this->siteGroups as $docRoot => $siteGroup) {
            try {
                $primaryDomain = $siteGroup['primary_domain'];
                $additionalDomains = $siteGroup['additional_domains'];
                
                // Create site entry in WHP with primary domain
                $siteId = $this->createWhpSite($primaryDomain, $siteGroup);
                
                if ($siteId) {
                    $createdSites[] = [
                        'domain' => $primaryDomain,
                        'site_id' => $siteId,
                        'type' => 'primary',
                        'document_root' => $docRoot,
                        'additional_domains' => $additionalDomains
                    ];
                    
                    $this->log("Created WHP site for primary domain: " . $primaryDomain);
                    
                    // Add additional domains to the same site
                    foreach ($additionalDomains as $additionalDomain) {
                        $this->addDomainToSite($siteId, $additionalDomain);
                        $this->log("Added additional domain to site: " . $additionalDomain);
                    }
                }
                
            } catch (Exception $e) {
                $this->log("Failed to create site for document root " . $docRoot . ": " . $e->getMessage(), 'error');
            }
        }
        
        return $createdSites;
    }
    
    /**
     * Map cPanel's phpversion field to the matching WHP container_type.id.
     * Strategy:
     *   1. Translate ea-phpXY to "X.Y" → "PHP X.Y" container type name.
     *   2. SELECT container_types WHERE name LIKE 'PHP X.Y%' AND active = 1.
     *   3. If no exact match, fall back to the highest-version active "PHP %" container type and warn.
     *
     * @return array{id:int, fallback:bool, reason:?string, min_cpu:float, min_memory:int}
     */
    private function resolveContainerTypeId(?string $cpanelPhpVersion): array {
        $key = strtolower(trim((string) $cpanelPhpVersion));
        $version = self::PHP_VERSION_MAP[$key] ?? null;

        if ($version !== null) {
            $stmt = $this->pdo->prepare(
                "SELECT id, min_cpu, min_memory FROM whp.container_types
                 WHERE name LIKE ? AND active = 1 ORDER BY id LIMIT 1"
            );
            $stmt->execute(['PHP ' . $version . '%']);
            if ($row = $stmt->fetch()) {
                return [
                    'id'         => (int) $row['id'],
                    'fallback'   => false,
                    'reason'     => null,
                    'min_cpu'    => (float) ($row['min_cpu'] ?? 0.10),
                    'min_memory' => (int)   ($row['min_memory'] ?? 128),
                ];
            }
        }

        // Fallback: newest active PHP container.
        $stmt = $this->pdo->prepare(
            "SELECT id, name, min_cpu, min_memory FROM whp.container_types
             WHERE name LIKE 'PHP %' AND active = 1
             ORDER BY name DESC LIMIT 1"
        );
        $stmt->execute();
        $row = $stmt->fetch();
        if (!$row) {
            throw new Exception('No active PHP container_types available');
        }
        $reason = $cpanelPhpVersion
            ? "cPanel phpversion '$cpanelPhpVersion' has no matching active WHP container; fell back to {$row['name']}"
            : "no phpversion supplied; fell back to {$row['name']}";
        return [
            'id'         => (int) $row['id'],
            'fallback'   => true,
            'reason'     => $reason,
            'min_cpu'    => (float) ($row['min_cpu'] ?? 0.10),
            'min_memory' => (int)   ($row['min_memory'] ?? 128),
        ];
    }

    /**
     * Create a WHP site entry
     */
    private function createWhpSite($domain, $domainData) {
        $php = $this->resolveContainerTypeId($domainData['phpversion'] ?? null);
        if ($php['fallback']) {
            $this->log($php['reason'], 'warning');
            $this->siteWarnings[] = [
                'domain'  => $domain,
                'kind'    => 'php_version_fallback',
                'message' => $php['reason'],
            ];
        }
        $containerTypeId = $php['id'];

        // Provision new sites with the minimum resources permitted by the
        // chosen container_type's schema floor. Customer can adjust upward
        // on the Sites page once they know their real traffic profile.
        // Fall back to a sensible floor (0.10 CPU / 128 MB) if the row's
        // min_* columns are NULL for any reason.
        $containerCount      = 1;
        $cpuPerContainer     = $php['min_cpu']    > 0 ? (float) $php['min_cpu']    : 0.10;
        $memoryPerContainer  = $php['min_memory'] > 0 ? (int)   $php['min_memory'] : 128;

        // Check if domain exists in WHP
        $stmt = $this->pdo->prepare("SELECT id, domain_name FROM whp.domains WHERE domain_name = ? AND username = ?");
        $stmt->execute([$domain, $this->username]);
        $domainRow = $stmt->fetch();
        
        if (!$domainRow) {
            // Create domain first
            $stmt = $this->pdo->prepare("
                INSERT INTO whp.domains (domain_name, username, active, created_at)
                VALUES (?, ?, 1, NOW())
            ");
            $stmt->execute([$domain, $this->username]);
            $domainId = $this->pdo->lastInsertId();
        } else {
            $domainId = $domainRow['id'];
        }

        // Reuse existing site if this domain is already linked to one (e.g.,
        // the WHP-on-signup primary domain). Importing should add/update DNS
        // records on the existing site, not duplicate it.
        $stmt = $this->pdo->prepare("
            SELECT s.id FROM whp.sites s
            JOIN whp.site_domains sd ON sd.site_id = s.id
            WHERE sd.domain_id = ? AND s.username = ?
            LIMIT 1
        ");
        $stmt->execute([$domainId, $this->username]);
        if ($existingSiteId = $stmt->fetchColumn()) {
            $this->log("Domain $domain already has WHP site id=$existingSiteId; reusing (no duplicate)", 'info');
            return (int) $existingSiteId;
        }

        // Create site directory
        $siteDir = '/docker/users/' . $this->username . '/' . $domain;
        if (!is_dir($siteDir)) {
            mkdir($siteDir, 0755, true);
            
            // Set ownership
            $uid = trim(shell_exec("id -u " . escapeshellarg($this->username)));
            $gid = trim(shell_exec("id -g " . escapeshellarg($this->username)));
            if (is_numeric($uid) && is_numeric($gid)) {
                shell_exec("chown -R {$uid}:{$gid} " . escapeshellarg($siteDir));
            }
        }
        
        // Insert site into database with proper structure
        $siteName = $domain . " (Imported from cPanel)";
        
        $stmt = $this->pdo->prepare("
            INSERT INTO whp.sites (
                site_name, 
                primary_domain_id, 
                username, 
                container_type_id, 
                container_count, 
                cpu_per_container, 
                memory_per_container, 
                user_variables, 
                auto_recreate_on_update,
                created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
        ");
        
        $stmt->execute([
            $siteName,
            $domainId,
            $this->username,
            $containerTypeId,
            $containerCount,
            $cpuPerContainer,
            $memoryPerContainer,
            '{}', // user_variables
            0 // auto_recreate_on_update
        ]);

        $siteId = $this->pdo->lastInsertId();

        // Insert site_domains primary row. Match the canonical pattern in
        // api/sites.php — populate fqdn (NULL fqdn here causes WHP's
        // site-management UI to insert a second is_primary=1 row when the
        // site is later edited, producing duplicate entries that show up
        // in the panel).
        $stmt = $this->pdo->prepare("
            INSERT INTO whp.site_domains (site_id, domain_id, fqdn, is_primary, is_wildcard)
            VALUES (?, ?, ?, 1, 0)
        ");
        $stmt->execute([$siteId, $domainId, $domain]);

        // Link any serveralias FQDNs (e.g., www.<domain>, mail.<domain>) so
        // the site serves them out of the same container. cPanel surfaces
        // these in `userdata/<domain>:serveralias` and parseUserdataFile
        // collects them into $domainData['aliases']. We walk each alias
        // back to its parent domain in whp.domains (matching the pattern
        // in api/sites.php for additional_domains).
        foreach (($domainData['aliases'] ?? []) as $alias) {
            $alias = trim((string) $alias);
            if ($alias === '' || $alias === $domain) continue;

            $parentId = null;
            $parts = explode('.', $alias);
            for ($i = 0; $i < count($parts); $i++) {
                $potential = implode('.', array_slice($parts, $i));
                $stmt = $this->pdo->prepare(
                    "SELECT id FROM whp.domains WHERE domain_name = ? AND username = ?"
                );
                $stmt->execute([$potential, $this->username]);
                $parentId = $stmt->fetchColumn();
                if ($parentId) break;
            }
            if (!$parentId) {
                $this->log("Skipping alias $alias (no parent domain found in whp.domains for {$this->username})", 'info');
                continue;
            }

            // Dedup against existing alias rows.
            $stmt = $this->pdo->prepare(
                "SELECT id FROM whp.site_domains WHERE site_id = ? AND domain_id = ? AND fqdn = ?"
            );
            $stmt->execute([$siteId, $parentId, $alias]);
            if ($stmt->fetchColumn()) continue;

            $stmt = $this->pdo->prepare(
                "INSERT INTO whp.site_domains (site_id, domain_id, fqdn, is_primary) VALUES (?, ?, ?, 0)"
            );
            $stmt->execute([$siteId, $parentId, $alias]);
            $this->log("Linked alias $alias to site $siteId", 'info');
        }

        // Tell the customer we provisioned at the floor — they should review
        // and bump up if their site needs more horsepower. Only emit when we
        // actually INSERT a new row; existing-reused sites already have
        // operator-chosen resources and the reuse branch returns early above.
        if (!isset($this->siteWarnings)) $this->siteWarnings = [];
        $this->siteWarnings[] = [
            'domain'  => $domain,
            'kind'    => 'review_site_resources',
            'message' => sprintf(
                'Site for %s was created with minimal resources (containers=%d, cpu=%s, memory=%dMB). Review and adjust on the Sites page if your traffic needs more.',
                $domain, $containerCount, (string) $cpuPerContainer, $memoryPerContainer
            ),
        ];

        return $siteId;
    }
    
    
    /**
     * Import files for sites based on document root groupings
     */
    private function importFiles($domains) {
        foreach ($this->siteGroups as $docRoot => $siteGroup) {
            $primaryDomain = $siteGroup['primary_domain'];
            $this->importSiteFiles($primaryDomain, $docRoot);
        }
    }
    
    /**
     * Import files for a specific site based on document root
     */
    private function importSiteFiles($primaryDomain, $documentRoot) {
        // Map cPanel document root to actual path in backup
        $sourceSubPath = $this->mapDocumentRootToPath($documentRoot);
        if ($sourceSubPath === null) {
            $msg = "Could not map cPanel document root '$documentRoot' for $primaryDomain "
                 . "(expected /home/<user>/<rest>); files NOT copied. Configure the site manually.";
            $this->log($msg, 'warning');
            $this->siteWarnings[] = [
                'domain'  => $primaryDomain,
                'kind'    => 'docroot_unmapped',
                'message' => $msg,
            ];
            return;
        }
        $sourcePath = $this->extractPath . "/*/homedir" . $sourceSubPath;
        $destPath = "/docker/users/{$this->username}/{$primaryDomain}/public_html";
        
        // Create destination directory
        if (!is_dir(dirname($destPath))) {
            mkdir(dirname($destPath), 0755, true);
        }
        
        // Find the actual source path (accounting for glob pattern)
        $actualSourcePaths = glob($sourcePath);
        if (empty($actualSourcePaths)) {
            $this->log("Source path not found for document root: " . $documentRoot, 'warning');
            return;
        }
        
        $actualSourcePath = $actualSourcePaths[0];
        
        // Copy files using rsync for better performance
        $command = "rsync -av " . escapeshellarg($actualSourcePath . "/") . " " . escapeshellarg($destPath . "/");
        $output = [];
        $returnCode = 0;
        
        exec($command, $output, $returnCode);
        
        if ($returnCode === 0) {
            $this->log("Files imported for site: " . $primaryDomain . " (document root: " . $documentRoot . ")");
        } else {
            $this->log("Failed to import files for site: " . $primaryDomain . " - " . implode(" ", $output), 'error');
        }
    }
    
    /**
     * Map cPanel document root to actual path in backup
     *
     * cPanel docroots are always /home/<user>/<rest> (or /home2/<user>/<rest>
     * on multi-volume hosts). Strip the user-home prefix; the remainder is
     * the relative path inside the cpmove's `homedir/` tree.
     *
     * @return string|null relative path under homedir/, or null if the input
     *                    can't be mapped (e.g. /var/www/...)
     */
    private function mapDocumentRootToPath($documentRoot) {
        if (preg_match('#^/home\d*/[^/]+(/.+)$#', $documentRoot, $m)) {
            return $m[1];  // e.g. "/public_html", "/Files.storytellersbrewery.com"
        }
        if ($documentRoot === '/public_html' || $documentRoot === '') {
            return '/public_html';  // tolerate already-relative legacy form
        }
        return null;
    }
    
    /**
     * Import MySQL databases
     */
    private function importDatabases($backupData) {
        foreach ($backupData['mysql_databases'] as $dbName => $sqlFile) {
            $this->importDatabase($dbName, $sqlFile);
        }
    }
    
    /**
     * Import a single database using WHP's mysqlmgmt class
     */
    private function importDatabase($originalDbName, $sqlFile) {
        // Initialize mysqlmgmt if not already done
        if (!isset($this->mysqlMgmt)) {
            if (!class_exists('mysqlmgmt')) {
                require_once('/docker/whp/web/libs/mysqlmgmt.php');
            }
            $this->mysqlMgmt = new mysqlmgmt();
        }
        
        // Generate WHP-compatible database name
        $random = substr(md5(uniqid()), 0, 8);
        $whpDbName = $this->username . '_' . preg_replace('/[^a-zA-Z0-9]/', '', $originalDbName) . '_' . $random;
        
        try {
            // Create database using WHP's method
            $result = $this->mysqlMgmt->createDatabase($whpDbName, $this->username);
            $created = isset($result['status']) && $result['status'] === '0';
            $alreadyExists = !$created && isset($result['msg'])
                && $this->isAlreadyExistsMessage($result['msg']);

            if ($alreadyExists) {
                // Re-import: db was created on a prior run. Skip create AND skip
                // SQL load — the data is presumably already there. Surface as
                // existing in the importer state so the summary is honest.
                $this->log("Database " . $originalDbName . " already imported (whp name " . $whpDbName . ") — skipping create and SQL load", 'info');
                $sqlFileSize = is_file($sqlFile) ? filesize($sqlFile) : null;
                $this->importedDatabases[] = [
                    'original_name' => $originalDbName,
                    'whp_name'      => $whpDbName,
                    'size_bytes'    => $sqlFileSize !== false ? $sqlFileSize : null,
                    'status'        => 'existing',
                ];
                return;
            }

            if ($created) {
                $this->log("Database created: " . $originalDbName . " -> " . $whpDbName);
                
                // Import SQL file
                $command = "mysql " . escapeshellarg($whpDbName) . " < " . escapeshellarg($sqlFile);
                $output = [];
                $returnCode = 0;
                
                exec($command, $output, $returnCode);
                
                if ($returnCode === 0) {
                    $this->log("Database imported: " . $originalDbName . " -> " . $whpDbName);
                    $sqlFileSize = is_file($sqlFile) ? filesize($sqlFile) : null;
                    $this->importedDatabases[] = [
                        'original_name' => $originalDbName,
                        'whp_name'      => $whpDbName,
                        'size_bytes'    => $sqlFileSize !== false ? $sqlFileSize : null,
                    ];
                } else {
                    throw new Exception("Failed to import database: " . implode("\n", $output));
                }
            } else {
                $this->log("Failed to create database " . $originalDbName . ": " . $result['msg'], 'error');
            }
            
        } catch (Exception $e) {
            $this->log("Database import failed for " . $originalDbName . ": " . $e->getMessage(), 'error');
        }
    }
    
    /**
     * Import MySQL users (deduplicated)
     */
    private function importMysqlUsers($backupData) {
        $uniqueUsers = [];
        
        // Deduplicate users from grants
        foreach ($backupData['mysql_grants'] as $grant) {
            $originalUser = $grant['username'];
            if (!isset($uniqueUsers[$originalUser])) {
                $uniqueUsers[$originalUser] = $grant;
            }
        }
        
        // Create each unique user once
        foreach ($uniqueUsers as $grant) {
            $this->createMysqlUser($grant);
        }
    }
    
    /**
     * Create a MySQL user with WHP naming convention using mysqlmgmt class
     */
    private function createMysqlUser($grant) {
        // Initialize mysqlmgmt if not already done
        if (!isset($this->mysqlMgmt)) {
            if (!class_exists('mysqlmgmt')) {
                require_once('/docker/whp/web/libs/mysqlmgmt.php');
            }
            $this->mysqlMgmt = new mysqlmgmt();
        }
        
        $originalUsername = $grant['username'];
        
        // Generate WHP-compatible username
        $random = substr(md5(uniqid()), 0, 8);
        $whpUsername = $this->username . '_' . $random;
        $password = bin2hex(random_bytes(16));
        
        try {
            // Create user using WHP's method
            $result = $this->mysqlMgmt->createMySQLUser($whpUsername, $password);
            $created = isset($result['status']) && $result['status'] === '0';
            $alreadyExists = !$created && isset($result['msg'])
                && $this->isAlreadyExistsMessage($result['msg']);

            if ($alreadyExists) {
                // Re-import: user was created on a prior run. Surface as info,
                // not error — the prior run's password is unrecoverable but
                // that's the price of idempotency on a credential we don't store.
                $this->log("MySQL user " . $originalUsername . " already imported (whp name " . $whpUsername . ") — skipping create", 'info');
            } elseif ($created) {
                $this->log("MySQL user created: " . $originalUsername . " -> " . $whpUsername);
            } else {
                $this->log("Failed to create MySQL user " . $originalUsername . ": " . $result['msg'], 'error');
            }

        } catch (Exception $e) {
            $this->log("Failed to create MySQL user " . $originalUsername . ": " . $e->getMessage(), 'error');
        }
    }
    
    /**
     * Create DNS records for imported domains using PowerDNS integration
     */
    private function createDnsRecords($domains) {
        // Initialize PowerDNS integration if not already done
        if (!isset($this->powerDNS)) {
            try {
                require_once('/docker/whp/web/libs/pdns-integration.php');
                $this->powerDNS = new PowerDNSIntegration($this->pdo);
            } catch (Exception $e) {
                $this->log("PowerDNS integration not available - skipping DNS record creation: " . $e->getMessage(), 'warning');
                return;
            }
        }
        
        foreach ($this->siteGroups as $docRoot => $siteGroup) {
            $primaryDomain = $siteGroup['primary_domain'];
            $additionalDomains = $siteGroup['additional_domains'];
            
            // Create DNS records for primary domain
            $this->createDomainDnsRecords($primaryDomain);
            
            // Create DNS records for additional domains
            foreach ($additionalDomains as $domain) {
                $this->createDomainDnsRecords($domain);
            }
        }
    }
    
    /**
     * Create DNS records for a specific domain using PowerDNS integration.
     *
     * First provisions a fresh PowerDNS zone with WHP defaults (SOA, NS,
     * nameserver A records via createZoneForDomain), then walks the cpmove's
     * BIND zone file and replicates the customer's records — skipping the
     * apex SOA/NS (already provisioned), cPanel-internal hostnames, and
     * unsupported types. MX records are preserved verbatim; the operator
     * decides repoint vs keep on the import results page.
     */
    private function createDomainDnsRecords($domain) {
        try {
            // Create zone using PowerDNS integration (same as domains.php).
            // If the zone already exists (e.g., domain was provisioned at
            // signup), treat that as non-fatal and proceed to add records.
            $zoneResult = $this->powerDNS->createZoneForDomain($domain, $this->username);
            $zoneAlreadyExisted = false;
            if (!is_array($zoneResult) || empty($zoneResult['success'])) {
                $err = is_array($zoneResult) ? ($zoneResult['error'] ?? '') : '';
                if ($this->isAlreadyExistsMessage($err)) {
                    $zoneAlreadyExisted = true;
                    $this->log("DNS zone for $domain already exists; will add/update records", 'info');
                } else {
                    $this->log("createZoneForDomain failed for $domain: $err", 'warning');
                    return; // bail on this domain; replicate would just fail too
                }
            } else {
                $this->log("DNS zone created for: $domain");
            }

            // Replicate cpmove DNS records on top of the (new or existing) zone.
            $stats = $this->replicateCpanelZoneRecords($domain);
            $this->log(sprintf(
                "DNS for %s%s: imported %d, skipped (internal) %d, skipped (apex SOA/NS) %d, skipped (unsupported) %d",
                $domain,
                $zoneAlreadyExisted ? ' (existing zone)' : '',
                $stats['imported'],
                $stats['skipped_internal'],
                $stats['skipped_apex_soa_ns'],
                $stats['skipped_unsupported']
            ), 'info');

            // Stash the preserved MX list so the summary writer can surface
            // the MX panel on the import results page.
            foreach ($stats['mx_preserved'] as $mx) {
                $this->dnsSummary['original_mx'][] = $mx;
            }

        } catch (Exception $e) {
            $this->log("Failed to create DNS records for " . $domain . ": " . $e->getMessage(), 'warning');
        }
    }

    /**
     * Return true if a record name is a cPanel-internal hostname that we
     * should drop when replicating the customer's zone — these point at the
     * old cPanel admin UIs (cpanel/whm/webdisk/webmail/cpcalendars/cpcontacts
     * plus their .files variants) and serve no purpose on WHP.
     */
    private function isCpanelInternalRecord(string $name, string $domain): bool {
        $rel = rtrim($name, '.');
        if ($rel === $domain) {
            return false;
        }
        if (str_ends_with($rel, '.' . $domain)) {
            $rel = substr($rel, 0, -strlen('.' . $domain));
        }
        return in_array(strtolower($rel), self::CPANEL_INTERNAL_HOSTNAMES, true);
    }

    /**
     * Parse a cpmove BIND zone file into a structured record list.
     *
     * Reads $this->extractPath . "/dnszones/$domain.db". Handles enough of
     * BIND syntax for cPanel cpmove reality (A, AAAA, CNAME, MX, TXT, NS,
     * SOA, SRV, PTR) but deliberately ignores BIND edge cases that don't
     * occur in cpmove output ($INCLUDE, fancy multi-line records, etc.).
     *
     * SOA's open-paren block (the only multi-line record cpmove ever emits)
     * is consumed-and-skipped: we don't need to import it because PowerDNS
     * already created an SOA via createZoneForDomain.
     *
     * @return array list of records, each:
     *   ['name' => FQDN with trailing '.', 'type' => string,
     *    'ttl' => int, 'content' => string, 'priority' => int|null]
     */
    private function parseCpanelZone(string $domain): array {
        $path = $this->backupRoot . "/dnszones/$domain.db";
        if (!is_file($path)) {
            $this->log("DNS zone file not found at $path; nothing to replicate", 'warning');
            return [];
        }

        $lines = file($path, FILE_IGNORE_NEW_LINES);
        if ($lines === false) {
            $this->log("Could not read DNS zone file $path", 'warning');
            return [];
        }

        $records = [];
        $defaultTtl = 3600;
        $origin = $domain . '.';
        $lastName = null;
        $inSoaParen = false;

        foreach ($lines as $rawLine) {
            // If we are inside a SOA (...) block, just look for the closing
            // paren and discard everything in between.
            if ($inSoaParen) {
                if (strpos($rawLine, ')') !== false) {
                    $inSoaParen = false;
                }
                continue;
            }

            $line = trim($rawLine);
            if ($line === '' || $line[0] === ';') {
                continue;
            }

            // Strip trailing inline comments (anything after ';' that isn't
            // inside a quoted string). cpmove rarely uses them outside SOA,
            // but be defensive.
            $line = $this->stripBindComment($line);
            if ($line === '') {
                continue;
            }

            // Directives.
            if (strncasecmp($line, '$TTL', 4) === 0) {
                $parts = preg_split('/\s+/', $line, 2);
                if (isset($parts[1]) && ctype_digit(trim($parts[1]))) {
                    $defaultTtl = (int) trim($parts[1]);
                }
                continue;
            }
            if (strncasecmp($line, '$ORIGIN', 7) === 0) {
                $parts = preg_split('/\s+/', $line, 2);
                if (isset($parts[1])) {
                    $origin = trim($parts[1]);
                    if (substr($origin, -1) !== '.') {
                        $origin .= '.';
                    }
                }
                continue;
            }
            if ($line[0] === '$') {
                // Some other directive ($INCLUDE, $GENERATE, …). cpmove doesn't
                // ship them; log and move on.
                $this->log("Skipping unsupported BIND directive: $line", 'warning');
                continue;
            }

            // Tokenize. TXT rdata uses quoted strings — preserve them.
            $tokens = $this->tokenizeBindLine($line);
            if (!$tokens) {
                continue;
            }

            // BIND lets a record line OMIT the name when it inherits from the
            // previous record. Detect: if the line starts with whitespace in
            // the original raw line, the first token is NOT a name.
            $nameOmitted = ($rawLine !== '' && (in_array($rawLine[0], [" ", "\t"], true)));

            $name = null;
            if ($nameOmitted) {
                $name = $lastName;
            } else {
                $name = array_shift($tokens);
            }

            if ($name === null) {
                continue;
            }

            // Optional TTL (numeric) and class (IN). Either, both, or neither
            // may be present in any order — but in practice cpmove always
            // emits "name ttl IN type rdata".
            $ttl = $defaultTtl;
            $type = null;

            // First non-name token: either ttl, class, or type.
            $next = array_shift($tokens);
            if ($next !== null && ctype_digit($next)) {
                $ttl = (int) $next;
                $next = array_shift($tokens);
            }
            if ($next !== null && strcasecmp($next, 'IN') === 0) {
                $next = array_shift($tokens);
            } elseif ($next !== null && ctype_digit($next)) {
                // Order swapped: class came first, then ttl.
                $ttl = (int) $next;
                $next = array_shift($tokens);
            }
            $type = $next;

            if ($type === null) {
                continue;
            }
            $type = strtoupper($type);

            // Normalize name to FQDN with trailing '.'.
            $fqdn = $this->bindNameToFqdn($name, $origin);
            $lastName = $name;

            // SOA: cpmove emits an open-paren multi-line block. Consume it
            // until we see ')'. We don't keep any of it — PDNS creates SOA.
            if ($type === 'SOA') {
                if (strpos($line, '(') !== false && strpos($line, ')') === false) {
                    $inSoaParen = true;
                }
                $records[] = [
                    'name'     => $fqdn,
                    'type'     => 'SOA',
                    'ttl'      => $ttl,
                    'content'  => '', // we won't use it; replicator skips SOA at apex
                    'priority' => null,
                ];
                continue;
            }

            // Refuse to handle multi-line non-SOA records — flag and skip.
            if (strpos($line, '(') !== false && strpos($line, ')') === false) {
                $this->log("Skipping multi-line non-SOA record (not supported): $line", 'warning');
                continue;
            }

            // Build content per type.
            $content = null;
            $priority = null;

            switch ($type) {
                case 'A':
                case 'AAAA':
                    $content = $tokens[0] ?? null;
                    break;

                case 'CNAME':
                case 'NS':
                case 'PTR':
                    $content = $tokens[0] ?? null;
                    if ($content !== null) {
                        $content = $this->bindNameToFqdn($content, $origin);
                    }
                    break;

                case 'MX':
                    if (count($tokens) >= 2 && ctype_digit($tokens[0])) {
                        $priority = (int) $tokens[0];
                        $content = $this->bindNameToFqdn($tokens[1], $origin);
                    } else {
                        $this->log("Skipping malformed MX record: $line", 'warning');
                        continue 2;
                    }
                    break;

                case 'TXT':
                    $content = $this->extractTxtContent($tokens);
                    break;

                case 'SRV':
                    // priority weight port target — keep as joined string;
                    // PDNS expects "priority weight port target".
                    if (count($tokens) >= 4) {
                        $tgt = $this->bindNameToFqdn($tokens[3], $origin);
                        $content = $tokens[0] . ' ' . $tokens[1] . ' ' . $tokens[2] . ' ' . $tgt;
                    } else {
                        $this->log("Skipping malformed SRV record: $line", 'warning');
                        continue 2;
                    }
                    break;

                default:
                    // Unknown / unsupported type — keep raw rdata, replicator
                    // will skip it.
                    $content = implode(' ', $tokens);
                    break;
            }

            if ($content === null || $content === '') {
                $this->log("Skipping record with empty content: $line", 'warning');
                continue;
            }

            $records[] = [
                'name'     => $fqdn,
                'type'     => $type,
                'ttl'      => $ttl,
                'content'  => $content,
                'priority' => $priority,
            ];
        }

        return $records;
    }

    /**
     * Strip a trailing inline BIND comment (`; …`) that isn't inside a
     * quoted string. Conservative — leaves the line unchanged when in doubt.
     */
    private function stripBindComment(string $line): string {
        $inQuote = false;
        $escaped = false;
        $len = strlen($line);
        for ($i = 0; $i < $len; $i++) {
            $c = $line[$i];
            if ($escaped) {
                $escaped = false;
                continue;
            }
            if ($c === '\\') {
                $escaped = true;
                continue;
            }
            if ($c === '"') {
                $inQuote = !$inQuote;
                continue;
            }
            if ($c === ';' && !$inQuote) {
                return rtrim(substr($line, 0, $i));
            }
        }
        return $line;
    }

    /**
     * Split a BIND zone line into tokens, treating "..." quoted strings as a
     * single token (and preserving their internal content verbatim, minus
     * the surrounding quotes). Backslash-escapes inside quoted strings keep
     * the next char literal.
     */
    private function tokenizeBindLine(string $line): array {
        $tokens = [];
        $buf = '';
        $inQuote = false;
        $escaped = false;
        $hasContent = false;
        $len = strlen($line);

        for ($i = 0; $i < $len; $i++) {
            $c = $line[$i];

            if ($escaped) {
                $buf .= $c;
                $escaped = false;
                $hasContent = true;
                continue;
            }
            if ($inQuote) {
                if ($c === '\\') {
                    $escaped = true;
                    continue;
                }
                if ($c === '"') {
                    $tokens[] = $buf;
                    $buf = '';
                    $hasContent = false;
                    $inQuote = false;
                    continue;
                }
                $buf .= $c;
                $hasContent = true;
                continue;
            }

            if ($c === '"') {
                if ($hasContent) {
                    $tokens[] = $buf;
                    $buf = '';
                    $hasContent = false;
                }
                $inQuote = true;
                continue;
            }

            if ($c === ' ' || $c === "\t") {
                if ($hasContent) {
                    $tokens[] = $buf;
                    $buf = '';
                    $hasContent = false;
                }
                continue;
            }

            $buf .= $c;
            $hasContent = true;
        }

        if ($hasContent) {
            $tokens[] = $buf;
        }

        return $tokens;
    }

    /**
     * Concatenate one or more TXT rdata tokens. cPanel-style TXT records may
     * arrive split into multiple "..." strings (DKIM keys often do); BIND
     * concatenates them as a single TXT value. tokenizeBindLine has already
     * stripped the quotes, so we just join with no separator.
     */
    private function extractTxtContent(array $tokens): string {
        return implode('', $tokens);
    }

    /**
     * Convert a BIND name (which may be relative or already FQDN) into an
     * FQDN with a trailing dot.
     */
    private function bindNameToFqdn(string $name, string $origin): string {
        if ($name === '@') {
            return $origin;
        }
        if (substr($name, -1) === '.') {
            return $name;
        }
        $sep = (substr($origin, 0, 1) === '.') ? '' : '.';
        return $name . $sep . $origin;
    }

    /**
     * Read the cpmove userdata/<domain> YAML and pull the top-level ip / ipv6
     * fields. Used to build the A/AAAA rewrite map during DNS replication so
     * records pointing at the old cPanel server get repointed at WHP rather
     * than continuing to route traffic to the source host post-migration.
     *
     * The userdata YAML for these fields is shallow-and-flat, so a line-based
     * parser produces the same result as yaml_parse_file().
     *
     * @return array{ipv4: ?string, ipv6: ?string}
     */
    private function readCpanelUserdataIp(string $domain): array {
        $path = $this->backupRoot . "/userdata/$domain";
        if (!is_file($path)) {
            return ['ipv4' => null, 'ipv6' => null];
        }

        // Prefer the YAML extension when present — handles edge cases like
        // quoted values uniformly. Fall through to a line-based parser otherwise.
        if (function_exists('yaml_parse_file')) {
            $parsed = @yaml_parse_file($path);
            if (is_array($parsed)) {
                $ipv4 = isset($parsed['ip']) && $parsed['ip'] !== '' && $parsed['ip'] !== null
                    ? (string)$parsed['ip'] : null;
                $ipv6 = isset($parsed['ipv6']) && $parsed['ipv6'] !== '' && $parsed['ipv6'] !== null && $parsed['ipv6'] !== '~'
                    ? (string)$parsed['ipv6'] : null;
                return ['ipv4' => $ipv4, 'ipv6' => $ipv6];
            }
        }

        $ipv4 = $ipv6 = null;
        foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
            if (preg_match('/^ip:\s*(\S+)\s*$/', $line, $m)) {
                $ipv4 = $m[1];
            }
            if (preg_match('/^ipv6:\s*(\S+)\s*$/', $line, $m)) {
                $v = $m[1];
                if ($v !== '~' && $v !== 'null' && $v !== '') {
                    $ipv6 = $v;
                }
            }
        }
        return ['ipv4' => $ipv4, 'ipv6' => $ipv6];
    }

    /**
     * Walk the parsed cpmove zone and replicate the records into PowerDNS.
     *
     * Filters:
     *   - apex SOA / NS for $domain — already provisioned by createZoneForDomain
     *   - cPanel-internal hostnames (isCpanelInternalRecord)
     *   - unsupported record types
     *
     * A/AAAA records whose content equals the cpmove userdata ip/ipv6 are
     * rewritten to point at the WHP server's IP so post-migration traffic
     * doesn't continue routing to the old cPanel host. Records pointing at
     * other IPs (customer-managed externals like AWS/CDN) are left untouched.
     *
     * MX records are preserved verbatim and also returned in mx_preserved so
     * the operator can choose repoint vs keep on the import results page.
     */
    private function replicateCpanelZoneRecords(string $domain): array {
        $stats = [
            'imported'             => 0,
            'skipped_internal'     => 0,
            'skipped_apex_soa_ns'  => 0,
            'skipped_unsupported'  => 0,
            'mx_preserved'         => [],
        ];

        $records = $this->parseCpanelZone($domain);
        if (!$records) {
            return $stats;
        }

        // Build the cpmove → WHP IP rewrite map for this domain.
        $cpanelIps   = $this->readCpanelUserdataIp($domain);
        $whpServerIp = method_exists($this->powerDNS, 'getServerIP') ? $this->powerDNS->getServerIP() : null;
        $rewriteMap  = [];
        if ($cpanelIps['ipv4'] && $whpServerIp) {
            $rewriteMap[$cpanelIps['ipv4']] = $whpServerIp;
        }
        // Note: if WHP later exposes a separate IPv6, add it here. For now we
        // leave AAAA records alone if there's no IPv6 to rewrite to (better to
        // keep the old AAAA than to silently drop or repoint to an IPv4).

        $supported = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'];
        $apex = $domain . '.';

        foreach ($records as $r) {
            $name = $r['name'];
            $type = $r['type'];

            // PowerDNS already has SOA + NS at the apex.
            if (($type === 'SOA' || $type === 'NS') && $name === $apex) {
                $stats['skipped_apex_soa_ns']++;
                $this->log("DNS skip (apex $type already provisioned): $name $type", 'info');
                continue;
            }

            if ($this->isCpanelInternalRecord($name, $domain)) {
                $stats['skipped_internal']++;
                $this->log("DNS skip (cPanel-internal): $name $type", 'info');
                continue;
            }

            if (!in_array($type, $supported, true)) {
                $stats['skipped_unsupported']++;
                $this->log("DNS skip (unsupported type $type): $name", 'info');
                continue;
            }

            // Rewrite cpmove server IP → WHP server IP for A/AAAA only.
            if (in_array($type, ['A', 'AAAA'], true) && isset($rewriteMap[$r['content']])) {
                $oldIp = $r['content'];
                $r['content'] = $rewriteMap[$oldIp];
                $this->log("Rewrote {$type} {$name}: $oldIp -> {$r['content']}", 'info');
                if (!isset($this->dnsSummary['rewritten_records'])) {
                    $this->dnsSummary['rewritten_records'] = [];
                }
                $this->dnsSummary['rewritten_records'][] = [
                    'name' => $name,
                    'type' => $type,
                    'from' => $oldIp,
                    'to'   => $r['content'],
                ];
            }

            try {
                // PowerDNS requires TXT record content to be wrapped in
                // double quotes per RFC 1035. Our parser already concatenated
                // and unquoted the BIND form, so re-quote (escaping internal
                // backslashes/quotes) before submitting.
                $contentToSubmit = $r['content'];
                if ($type === 'TXT') {
                    $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $contentToSubmit);
                    $contentToSubmit = '"' . $escaped . '"';
                }
                $ok = $this->powerDNS->addRecord(
                    $domain,
                    rtrim($name, '.'),
                    $type,
                    $contentToSubmit,
                    $r['ttl'],
                    $r['priority']
                );
                if ($ok) {
                    $stats['imported']++;
                    $this->log("DNS add: $name $type " . $r['content']
                        . ($r['priority'] !== null ? " (priority {$r['priority']})" : ''), 'info');
                    if ($type === 'MX') {
                        $stats['mx_preserved'][] = [
                            'host'     => $r['content'],
                            'priority' => $r['priority'],
                        ];
                    }
                } else {
                    $this->log("DNS add failed: $name $type {$r['content']}", 'warning');
                }
            } catch (Exception $e) {
                $this->log("DNS add exception ($name $type): " . $e->getMessage(), 'warning');
            }
        }

        return $stats;
    }
    
    /**
     * Check if DNS manager is available
     */
    private function isDnsManagerAvailable() {
        return file_exists('/home/jknapp/code/whp/web-files/includes/dns_manager.php');
    }
    
    /**
     * Get server IP address
     */
    private function getServerIp() {
        // Try to get from server settings first
        $settingsFile = '/docker/whp/settings.json';
        if (file_exists($settingsFile)) {
            $settings = json_decode(file_get_contents($settingsFile), true);
            if (!empty($settings['server_ip'])) {
                return $settings['server_ip'];
            }
        }
        
        // Fallback to detecting server IP
        $ip = trim(file_get_contents("http://ipv4.icanhazip.com"));
        if (filter_var($ip, FILTER_VALIDATE_IP)) {
            return $ip;
        }
        
        // Final fallback
        return '127.0.0.1';
    }
    
    /**
     * Add domain to existing site (for additional domains)
     */
    private function addDomainToSite($siteId, $domain) {
        try {
            // Check if domain exists in WHP
            $stmt = $this->pdo->prepare("SELECT id FROM whp.domains WHERE domain_name = ? AND username = ?");
            $stmt->execute([$domain, $this->username]);
            $domainRow = $stmt->fetch();
            
            if (!$domainRow) {
                // Create domain first
                $stmt = $this->pdo->prepare("
                    INSERT INTO whp.domains (domain_name, username, active, created_at) 
                    VALUES (?, ?, 1, NOW())
                ");
                $stmt->execute([$domain, $this->username]);
                $domainId = $this->pdo->lastInsertId();
            } else {
                $domainId = $domainRow['id'];
            }
            
            // Add to site_domains using the correct schema (domain_id, not domain)
            $stmt = $this->pdo->prepare("
                INSERT INTO whp.site_domains (site_id, domain_id, is_primary) 
                VALUES (?, ?, 0)
                ON DUPLICATE KEY UPDATE is_primary = 0
            ");
            $stmt->execute([$siteId, $domainId]);
            
        } catch (PDOException $e) {
            $this->log("Failed to add domain " . $domain . " to site " . $siteId . ": " . $e->getMessage(), 'error');
        }
    }
    
    /**
     * Cleanup temporary files
     */
    public function cleanup() {
        // Clean up extraction directory
        if (is_dir($this->extractPath)) {
            $this->removeDirectory($this->extractPath);
        }
        
        // Clean up backup file and its temp directory
        if (file_exists($this->backupPath)) {
            $tempDir = dirname($this->backupPath);
            unlink($this->backupPath);
            if (is_dir($tempDir) && strpos($tempDir, '/tmp/cpanel_processing_') === 0) {
                rmdir($tempDir);
            }
        }
        
        $this->log("Cleanup completed");
    }
    
    /**
     * Recursively remove directory
     */
    private function removeDirectory($dir) {
        if (is_dir($dir)) {
            $objects = scandir($dir);
            foreach ($objects as $object) {
                if ($object != "." && $object != "..") {
                    if (is_dir($dir . "/" . $object)) {
                        $this->removeDirectory($dir . "/" . $object);
                    } else {
                        unlink($dir . "/" . $object);
                    }
                }
            }
            rmdir($dir);
        }
    }
    
    /**
     * Log import progress
     */
    private function log($message, $level = 'info') {
        $timestamp = date('Y-m-d H:i:s');
        $logEntry = "[$timestamp] [$level] $message";

        $this->importLog[] = $logEntry;

        // Also log to system log
        error_log("CpanelBackupImporter: $logEntry");
    }

    /**
     * Push an intermediate progress percent + stage name to whp.cpanel_imports
     * so the panel's polling UI can show real movement during a long import,
     * not just 15% → 100%. No-op if the import_id hasn't been wired in yet
     * (older callers that never call setImportId).
     */
    private function reportProgress(int $pct, string $stage): void {
        $this->log("[stage=$stage] progress=$pct%", 'info');
        if (!$this->importId) return;
        try {
            $stmt = $this->pdo->prepare(
                "UPDATE whp.cpanel_imports SET progress = ? WHERE import_id = ?"
            );
            $stmt->execute([max(0, min(100, $pct)), $this->importId]);
        } catch (Exception $e) {
            // Reporting is best-effort — don't let a stale connection or
            // schema drift kill an otherwise-successful import.
            error_log('reportProgress UPDATE failed: ' . $e->getMessage());
        }
    }

    /**
     * Detect "this entity is already in the destination" responses from
     * Poste/MariaDB/etc. so importers can mark the row 'existing' instead
     * of 'failed'. Different upstreams phrase it differently — Poste uses
     * "already in database", emailmgmt uses "already exists", MySQL uses
     * "already exists" too. Add new phrasings here as we discover them.
     */
    private function isAlreadyExistsMessage(?string $msg): bool {
        if ($msg === null || $msg === '') return false;
        $needles = [
            'already exists',
            'already in database',
            'this combination of username and domain is already',
            'duplicate entry',
        ];
        foreach ($needles as $n) {
            if (stripos($msg, $n) !== false) return true;
        }
        return false;
    }
    
    /**
     * Get import log
     */
    public function getLog() {
        return $this->importLog;
    }

    /**
     * Return the in-flight email summary built up by importEmailAccounts /
     * importEmailForwarders.
     */
    public function getEmailSummary(): array {
        return $this->emailSummary;
    }

    /**
     * Return aggregate counts of what the import touched. Used by the worker
     * to populate the canonical summary JSON's `totals` block.
     */
    public function getCounts(): array {
        return [
            'sites'      => count($this->siteGroups ?? []),
            'databases'  => count($this->importedDatabases ?? []),
            'mailboxes'  => count($this->emailSummary['mailboxes'] ?? []),
            'forwarders' => count($this->emailSummary['forwarders'] ?? []),
        ];
    }

    /**
     * Return every domain the import actually touched: each siteGroup's primary
     * domain plus its serveralias-style additional_domains. Used to drive
     * importEmailAccounts/importEmailForwarders and the summary JSON.
     */
    public function getImportedDomains(): array {
        $out = [];
        foreach (($this->siteGroups ?? []) as $group) {
            if (!empty($group['primary_domain'])) {
                $out[] = $group['primary_domain'];
            }
            foreach (($group['additional_domains'] ?? []) as $d) {
                $out[] = $d;
            }
        }
        return array_values(array_unique($out));
    }

    /**
     * Return one site-row summary per docroot the import created.
     * NOTE: php_version is currently null because parseUserdataFile() does not
     * extract `phpversion` from cpanel userdata into the siteGroup struct.
     * Resolution happens later in createWhpSite() via resolveContainerTypeId.
     * If we ever start storing the resolved version on the siteGroup, surface
     * it here.
     */
    public function getImportedSites(): array {
        $out = [];
        foreach (($this->siteGroups ?? []) as $docRoot => $group) {
            $out[] = [
                'domain'      => $group['primary_domain'] ?? '',
                'docroot'     => $docRoot,
                'php_version' => $group['phpversion'] ?? null,
            ];
        }
        return $out;
    }

    /**
     * Return the list of databases successfully imported. Each entry has
     * original_name, whp_name, and size_bytes (the .sql file size on disk;
     * null if it could not be stat'd).
     */
    public function getImportedDatabases(): array {
        return $this->importedDatabases ?? [];
    }

    /**
     * Return the DNS rewrite/preserve summary collected during createDnsRecords:
     *   - original_mx[]: cpmove MX records preserved verbatim
     *   - rewritten_records[]: A/AAAA records whose content was rewritten
     *                          from the cPanel server IP to the WHP IP
     */
    public function getDnsSummary(): array {
        return $this->dnsSummary ?? ['original_mx' => [], 'rewritten_records' => []];
    }

    /**
     * Return per-site warnings (e.g. PHP-version fallbacks, requires_manual_setup)
     * accumulated during the import.
     */
    public function getSiteWarnings(): array {
        return $this->siteWarnings ?? [];
    }
    
    /**
     * Validate domain name format
     */
    private function isValidDomain($domain) {
        // Basic domain validation
        if (empty($domain) || strlen($domain) > 253) {
            return false;
        }

        // Check for valid characters and structure
        return preg_match('/^[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9]$/', $domain) === 1;
    }

    /**
     * Walk every domain's mailbox list and create the corresponding Poste mailboxes
     * via the existing emailmgmt helper. Each mailbox gets a freshly generated
     * 20-char temp password; on success the password is encrypted with the
     * per-import key and stashed in the in-flight email summary so the summary
     * writer can flush it to the per-import JSON file at the end of the run.
     *
     * Per-mailbox failures are non-fatal — they are recorded in the summary as
     * status=failed with the helper's error message and the worker continues.
     */
    public function importEmailAccounts(array $domains): void {
        require_once __DIR__ . '/CpanelImportSummary.php';
        if (!class_exists('emailmgmt')) {
            require_once('/docker/whp/web/libs/emailmgmt.php');
        }
        $emailMgr = new emailmgmt();

        foreach ($domains as $domain) {
            $mailboxes = $this->parseEmailAccounts($domain);
            foreach ($mailboxes as $mb) {
                $tempPassword = $this->generateMailboxPassword();
                $quotaMb = $mb['quota_mb'] ?? 1024;
                $result = $emailMgr->createEmailAccount(
                    $this->username, $mb['email'], $tempPassword, $domain, $quotaMb
                );
                $created = isset($result['status']) && $result['status'] === '0';
                $alreadyExists = !$created && isset($result['msg'])
                    && $this->isAlreadyExistsMessage($result['msg']);

                if ($alreadyExists) {
                    $status = 'existing';
                    $error = null;
                    $tempPwEncrypted = null; // we never knew the existing password
                } elseif ($created) {
                    $status = 'created';
                    $error = null;
                    $tempPwEncrypted = CpanelImportSummary::encryptTempPassword($this->importId, $tempPassword);
                } else {
                    $status = 'failed';
                    $error = $result['msg'] ?? 'unknown error';
                    $tempPwEncrypted = null;
                }

                $entry = [
                    'email'    => $mb['email'],
                    'domain'   => $domain,
                    'quota_mb' => $quotaMb,
                    'status'   => $status,
                    'error'    => $error,
                    'temp_password_encrypted' => $tempPwEncrypted,
                ];
                $this->emailSummary['mailboxes'][] = $entry;
                $this->log("Mailbox {$mb['email']}: $status" . ($error ? " — $error" : ''),
                           $status === 'failed' ? 'warning' : 'info');
            }
        }
    }

    /**
     * Walk every domain's forwarder/alias list and create the corresponding
     * forwarders via the existing emailmgmt helper. Catch-all rules that fail
     * to auto-create are flagged as manual_required (Poste doesn't always
     * accept catch-all programmatically). :fail: reject rules are skipped
     * outright with a warning. Per-forwarder failures are non-fatal.
     */
    public function importEmailForwarders(array $domains): void {
        if (!class_exists('emailmgmt')) {
            require_once('/docker/whp/web/libs/emailmgmt.php');
        }
        $emailMgr = new emailmgmt();

        foreach ($domains as $domain) {
            $mailboxes = $this->parseEmailAccounts($domain);
            $forwarders = $this->parseEmailForwarders($domain, $mailboxes);
            foreach ($forwarders as $f) {
                if ($f['type'] === 'reject') {
                    $this->emailSummary['warnings'][] = [
                        'kind'    => 'forwarder_reject_skipped',
                        'message' => "Skipped :fail: rule {$f['source']} → {$f['reason']}",
                    ];
                    continue;
                }
                if ($f['type'] === 'catchall') {
                    // Poste has no `*@domain` forwarder concept (Box.user pattern
                    // rejects `*`). Domain-level catch-all is configurable in
                    // the Poste admin UI as "domain bin," but most customers
                    // don't use catch-all so we skip silently with a warning
                    // rather than dumping a confusing failure into the summary.
                    $target = implode(',', $f['targets']);
                    $this->emailSummary['warnings'][] = [
                        'kind'    => 'catchall_skipped',
                        'message' => "Catch-all for {$domain} (would forward to $target) was not imported; "
                                   . "Poste handles catch-all via domain bin in the admin UI if you need it.",
                    ];
                    $this->log("Forwarder catch-all for {$domain} skipped (Poste has no *@domain support)", 'info');
                    continue;
                }
                $result = $emailMgr->createEmailForwarder(
                    $this->username, $f['source'], implode(',', $f['targets'])
                );
                $created = isset($result['status']) && $result['status'] === '0';
                $alreadyExists = !$created && isset($result['msg'])
                    && $this->isAlreadyExistsMessage($result['msg']);

                if ($alreadyExists) {
                    $status = 'existing';
                    $error = null;
                } elseif ($created) {
                    $status = 'created';
                    $error = null;
                } else {
                    $status = 'failed';
                    $error = $result['msg'] ?? 'unknown error';
                }

                $entry = [
                    'type'    => $f['type'],
                    'source'  => $f['source'],
                    'targets' => $f['targets'],
                    'status'  => $status,
                    'error'   => $error,
                ];

                $this->emailSummary['forwarders'][] = $entry;
                $this->log("Forwarder {$f['source']} → " . implode(',', $f['targets']) . ": $status" .
                           ($error ? " — $error" : ''),
                           $status === 'failed' ? 'warning' : 'info');
            }
        }
    }

    /**
     * Generate a 20-char random password from a Poste-API-safe alphabet.
     * Used to seed mailbox temp passwords during cpmove import.
     */
    private function generateMailboxPassword(): string {
        $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#%^*+_-';
        $len = strlen($alphabet);
        $out = '';
        for ($i = 0; $i < 20; $i++) {
            $out .= $alphabet[random_int(0, $len - 1)];
        }
        return $out;
    }

    /**
     * Parse cpmove email accounts for a domain from homedir/etc/$domain/{passwd,quota}.
     * Returns a list of ['local','email','quota_bytes','quota_mb'] (quota fields may be null).
     * The shadow file is intentionally ignored — its hashes are unusable on the Poste API side.
     */
    private function parseEmailAccounts(string $domain): array {
        $passwdPath = $this->backupRoot . "/homedir/etc/$domain/passwd";
        $quotaPath  = $this->backupRoot . "/homedir/etc/$domain/quota";
        if (!is_file($passwdPath)) return [];

        $quotas = [];
        if (is_file($quotaPath)) {
            foreach (file($quotaPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2 && ctype_digit($parts[1])) {
                    $quotas[$parts[0]] = (int) $parts[1];
                }
            }
        }

        $mailboxes = [];
        foreach (file($passwdPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
            $parts = explode(':', $line);
            if (count($parts) < 6) continue;
            $local = $parts[0];
            if ($local === '' || strpos($local, '..') !== false) continue;
            $bytes = $quotas[$local] ?? null;
            $mailboxes[] = [
                'local'       => $local,
                'email'       => $local . '@' . $domain,
                'quota_bytes' => $bytes,
                'quota_mb'    => $bytes !== null ? max(1, (int) round($bytes / 1024 / 1024)) : null,
            ];
        }
        return $mailboxes;
    }

    /**
     * Parse cpmove forwarders/aliases for a domain from va/$domain (exim virtual-alias map).
     * Returns three categories: 'forward', 'catchall', 'reject' (':fail:' targets).
     *
     * The $mailboxes parameter is currently unused but kept in the signature: later tasks may
     * filter forwarder targets against the local mailbox list, and changing the signature later
     * would ripple to every caller.
     */
    private function parseEmailForwarders(string $domain, array $mailboxes): array {
        $vaPath = $this->backupRoot . "/va/$domain";
        if (!is_file($vaPath)) return [];

        $forwarders = [];
        foreach (file($vaPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
            if (preg_match('/^\s*#/', $line)) continue;
            if (strpos($line, ':') === false) continue;
            [$source, $target] = array_map('trim', explode(':', $line, 2));
            if ($source === '' || $target === '') continue;

            if (stripos($target, ':fail:') === 0) {
                $forwarders[] = [
                    'type' => 'reject', 'source' => $source, 'targets' => [],
                    'reason' => trim(substr($target, 6)),
                ];
                continue;
            }

            $targets = array_filter(array_map('trim', explode(',', $target)));
            $targets = array_map(function ($t) use ($domain) {
                return strpos($t, '@') === false ? $t . '@' . $domain : $t;
            }, $targets);

            if ($source === '*') {
                $forwarders[] = ['type' => 'catchall', 'source' => '*@' . $domain, 'targets' => array_values($targets)];
            } else {
                $forwarders[] = [
                    'type'    => 'forward',
                    'source'  => (strpos($source, '@') === false ? $source . '@' . $domain : $source),
                    'targets' => array_values($targets),
                ];
            }
        }
        return $forwarders;
    }

    /**
     * Get directory size recursively
     */
    private function getDirectorySize($directory) {
        $size = 0;
        
        if (!is_dir($directory)) {
            return 0;
        }
        
        try {
            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS)
            );
            
            foreach ($iterator as $file) {
                if ($file->isFile()) {
                    $size += $file->getSize();
                }
            }
        } catch (Exception $e) {
            $this->log("Failed to calculate directory size: " . $e->getMessage(), 'warning');
            return 0;
        }
        
        return $size;
    }
    
    /**
     * Create rollback point for import
     */
    private function createRollbackPoint() {
        $rollbackData = [
            'timestamp' => time(),
            'username' => $this->username,
            'sites_before_import' => $this->getCurrentUserSites(),
            'databases_before_import' => $this->getCurrentUserDatabases()
        ];
        
        $rollbackFile = "/tmp/cpanel_rollback_" . $this->username . "_" . uniqid() . ".json";
        file_put_contents($rollbackFile, json_encode($rollbackData, JSON_PRETTY_PRINT));
        
        $this->log("Rollback point created: " . $rollbackFile);
        return $rollbackFile;
    }
    
    /**
     * Get current user sites for rollback
     */
    private function getCurrentUserSites() {
        try {
            $stmt = $this->pdo->prepare("SELECT id, domain FROM sites WHERE username = ?");
            $stmt->execute([$this->username]);
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            $this->log("Failed to get current sites for rollback: " . $e->getMessage(), 'warning');
            return [];
        }
    }
    
    /**
     * Get current user databases for rollback
     */
    private function getCurrentUserDatabases() {
        try {
            $stmt = $this->pdo->prepare("SHOW DATABASES LIKE ?");
            $stmt->execute([$this->username . '_%']);
            return $stmt->fetchAll(PDO::FETCH_COLUMN);
        } catch (PDOException $e) {
            $this->log("Failed to get current databases for rollback: " . $e->getMessage(), 'warning');
            return [];
        }
    }
    
    /**
     * Perform rollback using rollback file
     */
    public function performRollback($rollbackFile) {
        if (!file_exists($rollbackFile)) {
            throw new Exception("Rollback file not found: " . $rollbackFile);
        }
        
        $rollbackData = json_decode(file_get_contents($rollbackFile), true);
        if (!$rollbackData) {
            throw new Exception("Invalid rollback file format");
        }
        
        $this->log("Starting rollback process");
        
        // Remove sites created during import
        $currentSites = $this->getCurrentUserSites();
        $originalSites = $rollbackData['sites_before_import'] ?? [];
        $originalSiteIds = array_column($originalSites, 'id');
        
        foreach ($currentSites as $site) {
            if (!in_array($site['id'], $originalSiteIds)) {
                $this->removeSite($site['id']);
                $this->log("Removed site: " . $site['domain']);
            }
        }
        
        // Remove databases created during import
        $currentDatabases = $this->getCurrentUserDatabases();
        $originalDatabases = $rollbackData['databases_before_import'] ?? [];
        
        foreach ($currentDatabases as $database) {
            if (!in_array($database, $originalDatabases)) {
                $this->removeDatabase($database);
                $this->log("Removed database: " . $database);
            }
        }
        
        // Clean up rollback file
        unlink($rollbackFile);
        
        $this->log("Rollback completed successfully");
    }
    
    /**
     * Remove a site (placeholder - integrate with your site management)
     */
    private function removeSite($siteId) {
        try {
            $stmt = $this->pdo->prepare("DELETE FROM sites WHERE id = ? AND username = ?");
            $stmt->execute([$siteId, $this->username]);
        } catch (PDOException $e) {
            $this->log("Failed to remove site " . $siteId . ": " . $e->getMessage(), 'error');
        }
    }
    
    /**
     * Remove a database
     */
    private function removeDatabase($databaseName) {
        try {
            $stmt = $this->pdo->prepare("DROP DATABASE IF EXISTS " . $databaseName);
            $stmt->execute();
        } catch (PDOException $e) {
            $this->log("Failed to remove database " . $databaseName . ": " . $e->getMessage(), 'error');
        }
    }
}