<?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<string>|null whitelist of docroot keys (the same keys
     * parseUserdata() builds into $this->siteGroups) the operator chose to
     * import during the analyze-then-confirm flow. NULL means "no filter"
     * (matches the skip_selection=1 / WHMCS / pre-two-phase fast path,
     * import everything).
     */
    private $selectedSiteGroups = null;

    /**
     * @var bool when true, importBackup() runs ONLY the extract + parse phase,
     * writes the manifest, then returns without creating sites / copying
     * files / importing DBs. The second worker pass clears this flag and
     * resumes with the operator's selection applied.
     */
    private $analyzeOnly = false;

    /**
     * @var string|null override for $this->extractPath. When the second
     * worker pass picks up a manifest from the first pass, it asks the
     * importer to reuse the on-disk extraction the first pass left behind
     * (so we don't tar -xzf the same multi-GB tarball twice).
     */
    private $extractPathOverride = 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 = [];

    /**
     * @var array<string,string> original cPanel DB name → renamed WHP DB name.
     * Populated alongside importedDatabases so replayMysqlGrants() and
     * rewriteWpConfigs() can translate cpmove-side identifiers to WHP names.
     */
    private $dbRenameMap = [];

    /**
     * @var array<string,string> original cPanel MySQL user → renamed WHP user.
     */
    private $userRenameMap = [];

    /**
     * @var array list of MySQL users successfully imported from cpmove. Each entry is
     *            ['original_name' => string, 'whp_name' => string, 'status' => 'created'|'existing'].
     *            Populated by createMysqlUser() on success; consumed by getImportedMysqlUsers()
     *            so the summary JSON exposes the cpanel→whp user rename map (the backfill
     *            script that reconstructs whp.database_users rows reads this list).
     */
    private $importedMysqlUsers = [];

    /**
     * @var array<string,string> original cPanel MySQL user → freshly generated
     * password for the renamed WHP user. Held only for the duration of the
     * import so rewriteWpConfigs() can write it into the on-disk wp-config.php
     * files; not persisted anywhere else (the password is then only recoverable
     * from the wp-config until the customer rotates it).
     */
    private $userPasswordMap = [];

    /**
     * @var bool|null cached read of /docker/whp/settings.json shared_httpd default_mode.
     * Determines whether the importer picks "PHP-FPM X.Y" or "PHP X.Y" rows.
     */
    private $sharedHttpdDefault = null;

    /**
     * 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;
    }

    /**
     * Restrict the upcoming import to the listed docroot keys (the keys of
     * $this->siteGroups, which parseUserdata() builds from cpmove's userdata/
     * directory keyed by document_root). Any siteGroup not on this list is
     * filtered out before createSites() / importFiles().
     *
     * Pass NULL (the default) to disable filtering — every siteGroup the
     * parser found gets imported. This is the legacy behaviour and the
     * skip_selection=1 / WHMCS path.
     *
     * Keys are the literal cPanel docroot strings (e.g. "/public_html",
     * "/forge.example.com"). Unknown keys are ignored silently — the worker
     * just imports whatever overlap exists.
     *
     * @param array<int,string>|null $docroots
     */
    public function setSelectedSiteGroups(?array $docroots): void {
        if ($docroots === null) {
            $this->selectedSiteGroups = null;
            return;
        }
        // Defensive: dedup + drop non-strings; trim each value.
        $clean = [];
        foreach ($docroots as $d) {
            if (!is_string($d) || $d === '') continue;
            $clean[$d] = true;
        }
        $this->selectedSiteGroups = array_keys($clean);
    }

    /**
     * Override the path the importer extracts to / reads its already-extracted
     * cpmove tree from. Used by the second pass of the two-phase flow so we
     * don't re-extract a multi-GB tarball that the analyze pass already
     * unpacked. When set, importBackup() skips extractBackup() entirely and
     * uses the override as $this->extractPath.
     */
    public function setExtractPathOverride(string $path): void {
        $this->extractPathOverride = $path;
        $this->extractPath = $path;
    }

    /**
     * Toggle analyze-only mode. When true, importBackup() runs the extract
     * and parse phases and returns the discovered structure, but does NOT
     * create sites, copy files, import databases, replay grants, create DNS,
     * provision containers, or import mailboxes. The extraction tree is left
     * on disk for the resume pass to pick up via setExtractPathOverride().
     */
    public function setAnalyzeOnly(bool $on): void {
        $this->analyzeOnly = $on;
    }

    /**
     * Run the parse-only phase of a cPanel backup import: extract the tarball,
     * walk userdata/addons/subdomains, enumerate databases + grants, count
     * mailboxes per domain. Writes a manifest JSON file describing what was
     * found and returns the same structure to the caller.
     *
     * Does NOT create sites, copy files, import DBs, or touch DNS. The
     * extraction directory is intentionally left in place ($this->extractPath)
     * so the second worker pass can pick it up via setExtractPathOverride()
     * without re-extracting the tarball.
     *
     * The manifest lives at /var/lib/whp/cpanel-imports/<import_id>.manifest.json.
     * The selector UI reads it; api/cpanel-import-ajax.php?action=confirm_selection
     * writes the operator's pick to whp.cpanel_imports.selected_site_groups
     * and kicks off the resume pass.
     *
     * @param string $backupFilePath absolute path to the cpmove .tar.gz under
     *                               /docker/users/<u>/userfiles/
     * @return array the manifest (also written to disk)
     */
    public function analyzeBackup(string $backupFilePath): array {
        $this->analyzeOnly = true;
        $result = $this->importBackup($backupFilePath);
        // importBackup() in analyze mode returns the manifest array directly
        // (see the early-return branch added below). Re-shape for callers.
        return $result;
    }

    /**
     * Build the analyze-phase manifest from $this->siteGroups (populated by
     * parseUserdata) and the $backupData blob from parseBackupStructure().
     * The manifest is the source of truth for the selector UI.
     *
     * Per-row fields:
     *   docroot              the unique key the operator selects on
     *   primary_domain       the cPanel "main" domain mapped to this docroot
     *   additional_domains   cPanel serveralias/addon domains pointing here
     *   phpversion           cPanel's ea-php* tag (or null if unset)
     *   mailbox_count        number of mailboxes under homedir/etc/<domain>/passwd
     *                        for every domain in this group
     *   databases            cpmove DB-name guesses for this group (heuristic:
     *                        the DB name contains the cPanel-side primary
     *                        domain stem with non-alphanum stripped). Unmatched
     *                        DBs go into the top-level orphan_databases bucket.
     *
     * Top-level fields:
     *   import_id            same as the row in cpanel_imports
     *   username             target user
     *   site_groups          the rows described above
     *   orphan_databases     DBs the heuristic couldn't tie to any site group
     *   total_mailbox_count  sum across all groups
     *   total_db_count       count of databases
     *   generated_at         ISO8601 UTC
     */
    private function buildAnalyzeManifest(array $backupData): array {
        $groups = [];
        $allDbNames = array_keys($backupData['mysql_databases'] ?? []);
        $matchedDbs = [];

        foreach (($this->siteGroups ?? []) as $docRoot => $group) {
            $primary = $group['primary_domain'] ?? '';
            $domains = array_filter(array_merge(
                [$primary],
                $group['additional_domains'] ?? []
            ));

            // Mailbox count across this group's domains.
            $mailboxCount = 0;
            foreach ($domains as $d) {
                $mailboxCount += count($this->parseEmailAccounts($d));
            }

            // Heuristic DB matching. cPanel DBs are usually named
            // <user>_<short-tag>; without per-DB usage data we can't tie
            // them to a docroot rigorously. Use the primary domain's stem
            // (strip TLD, lowercase, strip non-alphanum) and look for that
            // substring inside the DB name. This is best-effort; the UI
            // makes clear DB matching is approximate.
            $primaryStem = $this->dbStemFromDomain($primary);
            $groupDbs = [];
            foreach ($allDbNames as $dbName) {
                if ($primaryStem !== '' && stripos($dbName, $primaryStem) !== false) {
                    $groupDbs[] = $dbName;
                    $matchedDbs[$dbName] = true;
                }
            }

            $groups[] = [
                // docroot is the selection key (matches the array key of
                // $this->siteGroups so applySiteGroupSelection() can filter
                // in O(1) by hash).
                'docroot'            => $docRoot,
                'primary_domain'     => $primary,
                'additional_domains' => array_values($group['additional_domains'] ?? []),
                'phpversion'         => $group['phpversion'] ?? null,
                'mailbox_count'      => $mailboxCount,
                'databases'          => $groupDbs,
            ];
        }

        // Orphan DBs — the heuristic didn't match these to any group.
        // Surface them so the operator knows they exist; in v1 they get
        // imported alongside any selected group (DB-level selection is a
        // deferred v1.1 enhancement noted in the report).
        $orphanDbs = [];
        foreach ($allDbNames as $dbName) {
            if (empty($matchedDbs[$dbName])) $orphanDbs[] = $dbName;
        }

        $totalMailboxes = 0;
        foreach ($groups as $g) $totalMailboxes += $g['mailbox_count'];

        return [
            'import_id'           => $this->importId,
            'username'            => $this->username,
            'site_groups'         => $groups,
            'orphan_databases'    => $orphanDbs,
            'total_mailbox_count' => $totalMailboxes,
            'total_db_count'      => count($allDbNames),
            'extract_path'        => $this->extractPath,
            'backup_root'         => $this->backupRoot,
            'generated_at'        => gmdate('c'),
        ];
    }

    /**
     * Reduce a domain to the alphanumeric stem that cPanel typically uses
     * as the DB-name prefix. Example: "alsacorp.com" → "alsacorp".
     * Used only for the analyze manifest's best-effort DB-to-site mapping.
     */
    private function dbStemFromDomain(string $domain): string {
        if ($domain === '') return '';
        $stem = strtolower(explode('.', $domain)[0] ?? '');
        return preg_replace('/[^a-z0-9]/', '', $stem) ?? '';
    }

    /**
     * Write the analyze manifest to /var/lib/whp/cpanel-imports/<id>.manifest.json.
     * 0600 + parent dir 0700 so a curious customer running CLI php on the
     * worker host can't read it.
     */
    private function writeManifestFile(array $manifest): void {
        if ($this->importId === null) {
            $this->log("writeManifestFile: no importId set; skipping manifest write", 'warning');
            return;
        }
        $dir = '/var/lib/whp/cpanel-imports';
        if (!is_dir($dir)) {
            @mkdir($dir, 0700, true);
        }
        $path = $dir . '/' . $this->importId . '.manifest.json';
        $payload = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if ($payload === false) {
            throw new Exception("Failed to encode analyze manifest: " . json_last_error_msg());
        }
        if (file_put_contents($path, $payload, LOCK_EX) === false) {
            throw new Exception("Failed to write analyze manifest to $path");
        }
        @chmod($path, 0600);
        $this->log("Wrote analyze manifest: $path", 'info');
    }

    /**
     * Filter $this->siteGroups in place down to only the docroot keys the
     * operator selected via setSelectedSiteGroups(). Called BEFORE
     * createSites / importFiles / etc. so every downstream stage sees the
     * reduced set automatically.
     *
     * No-op (and a warning log) if the selection list is empty after
     * intersection — that's almost certainly an operator mistake; the
     * worker continues but no sites will be created, which is loud enough
     * in the import log + results page to surface the issue.
     */
    private function applySiteGroupSelection(): void {
        if ($this->selectedSiteGroups === null) return;

        $wanted = array_flip($this->selectedSiteGroups);
        $before = count($this->siteGroups);
        $kept = [];
        foreach ($this->siteGroups as $docRoot => $group) {
            if (isset($wanted[$docRoot])) {
                $kept[$docRoot] = $group;
            }
        }
        $this->siteGroups = $kept;
        $after = count($kept);
        $this->log(
            "Applied site-group selection: kept $after of $before site groups "
            . "(selected docroots: " . implode(', ', $this->selectedSiteGroups) . ")",
            'info'
        );
        if ($after === 0) {
            $this->log(
                "applySiteGroupSelection: zero site groups matched the selection — "
                . "the import will create no sites. Check the selected docroot "
                . "keys against the analyze manifest.",
                'warning'
            );
        }
    }


    /**
     * 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();

            // Pre-extraction symlink audit. tarballs from compromised cPanel
            // accounts (e.g. ALFA TEaM shell-infected WordPress installs)
            // sometimes carry symlinks whose absolute target is a real host
            // path — /, /etc, /usr, /root, etc. The fixed removeDirectory()
            // no longer traverses them post-extraction, but we'd rather not
            // extract the tarball at all if it's flagged; the symlinks are
            // a strong indicator the source server is compromised, and
            // proceeding silently would copy a webshell onto WHP.
            //
            // DANGEROUS findings abort the import here (before extract,
            // before any disk writes). UNCERTAIN ones are logged-and-pass-
            // through for operator review. BENIGN ones are silent.
            $symlinkFindings = $this->scanTarballForDangerousSymlinks($this->backupPath);
            $dangerous = array_values(array_filter($symlinkFindings, fn($f) => $f['type'] === 'DANGEROUS'));
            $uncertain = array_values(array_filter($symlinkFindings, fn($f) => $f['type'] === 'UNCERTAIN'));
            if (!empty($dangerous)) {
                $lines = [];
                foreach ($dangerous as $f) {
                    $lines[] = "  " . $f['archive_path'] . " -> " . $f['target'] . "  (" . $f['reason'] . ")";
                }
                throw new Exception(
                    "cPanel backup contains symlinks pointing at real host paths "
                    . "(strong indicator of a webshell / symlink-traversal payload "
                    . "such as ALFA TEaM Shell on the source server). Import aborted "
                    . "before extraction. The customer's source server is likely "
                    . "compromised and should be investigated before retrying.\n"
                    . "Offending entries:\n" . implode("\n", $lines)
                );
            }
            if (!empty($uncertain)) {
                foreach ($uncertain as $f) {
                    $this->log(
                        "Pre-extract symlink scan: UNCERTAIN — " . $f['archive_path']
                        . " -> " . $f['target'] . " (" . $f['reason'] . ")",
                        'warning'
                    );
                }
            }

            // Extract the backup tarball. SKIP if an override path was set
            // (the analyze pass already extracted; we reuse its tree to avoid
            // a second multi-GB tar -xzf).
            if ($this->extractPathOverride !== null) {
                $this->log("Reusing prior extraction at: " . $this->extractPath, 'info');
                $this->reportProgress(25, 'extract_reused');
            } else {
                $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);

            // --- analyze-only short-circuit -------------------------------
            // If the caller asked for parse-only (analyzeBackup() / the
            // first pass of the two-phase flow), build the manifest now,
            // write it to /var/lib/whp/cpanel-imports/<import_id>.manifest.json,
            // and return. We intentionally do NOT cleanup() the extraction
            // tree — the second pass will reuse it via setExtractPathOverride.
            if ($this->analyzeOnly) {
                $manifest = $this->buildAnalyzeManifest($backupData);
                $this->writeManifestFile($manifest);
                $this->reportProgress(40, 'analyzed');
                return [
                    'success'      => true,
                    'analyze_only' => true,
                    'manifest'     => $manifest,
                    'extract_path' => $this->extractPath,
                    'log'          => $this->importLog,
                ];
            }
            // --- end analyze-only short-circuit ---------------------------

            // Operator-selected site groups (set by the resume pass via
            // setSelectedSiteGroups()). Filter $this->siteGroups in place
            // BEFORE createSites/importFiles/etc. consume it. NULL = no
            // filter, import everything (legacy / skip_selection=1 path).
            if ($this->selectedSiteGroups !== null) {
                $this->applySiteGroupSelection();
                $this->reportProgress(38, 'selection_applied');
            }

            // 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');

            // Replay original cPanel grants against the renamed users + DBs.
            // mysqlmgmt's createMySQLUser only grants on `<self>` and `<self>_%`
            // patterns, which match a fresh account's owned DBs but NOT the
            // cpanel-renamed DBs (which keep an `<owner>_<originalname>_<hash>`
            // shape). Without this step the imported users cannot read or
            // write the imported DBs.
            $this->replayMysqlGrants($backupData);

            // Rewrite wp-config.php (and any other recognized DB connection
            // strings) under each site's docroot so apps find the new DB on
            // the `mysql` container with the renamed credentials. Runs after
            // grants are in place so the WP install can actually connect on
            // first hit.
            $this->rewriteAppDbConfigs();

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

            // Provision the docker containers for each site so the customer
            // can hit the imported sites without having to click 'Create
            // Containers' in the panel per-site. Mirrors the logic in
            // api/sites.php's 'create_containers' action. Errors on a single
            // site are logged but don't fail the import — the panel button
            // remains the manual recovery path.
            $this->provisionSiteContainers($sites);
            $this->reportProgress(90, 'containers_provisioned');

            // 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. Hardening flags:
        //   --no-same-owner / --no-same-permissions strip uid/perm bits from
        //     the archive (cpmove can carry setuid binaries we don't want).
        //   --no-overwrite-dir refuses to clobber an existing directory's
        //     metadata, which is one of the historical tar-symlink-escape
        //     vectors (combined with modern GNU tar's symlink-during-extract
        //     refusal, this closes the path-traversal class entirely).
        $command = "cd " . escapeshellarg($this->extractPath)
                 . " && tar --no-same-owner --no-same-permissions --no-overwrite-dir"
                 . "       -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 = [];

        // Capture user, host AND target database from each GRANT line. The
        // original parser kept only user+host, which dropped the cpanel-side
        // user→db relationship — without it, replayMysqlGrants() can't issue
        // the right GRANTs after rename, and the imported user ends up only
        // with the default `<user>` / `<user>_%` grants from mysqlmgmt's
        // createMySQLUser (which never match the renamed-DB pattern). We
        // tolerate backtick-quoted or bare database identifiers and skip
        // *.* global grants (they don't apply post-import).
        preg_match_all(
            "/GRANT\s+.+?\s+ON\s+(?:`([^`]+)`|([A-Za-z0-9_]+))\.\*\s+TO\s+'([^']+)'@'([^']+)'/i",
            $content, $matches, PREG_SET_ORDER
        );

        foreach ($matches as $m) {
            $rawDb = $m[1] !== '' ? $m[1] : $m[2];
            // cpmove's mysql.sql emits LIKE-safe escapes inside backticked DB
            // identifiers (e.g. `alsacorp\_alsa1`). MySQL accepts them as
            // literal underscores when parsing the GRANT, but our $dbRenameMap
            // is keyed off bare filename stems (`alsacorp_alsa1`). Without
            // this unescape every grant misses the rename map and 100% of
            // GRANTs are silently skipped, leaving imported sites unable to
            // connect to their DBs. See memory note
            // feedback_cpanel_mysql_grants_like_escapes for the full
            // diagnosis. DO NOT remove this without a regression test for
            // alsacorp / darkside-style imports.
            $db = str_replace(['\\_', '\\%'], ['_', '%'], $rawDb);
            $grants[] = [
                'username' => $m[3],
                'host'     => $m[4],
                'database' => $db,
            ];
        }

        $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" and prefer the container-type family
     *      that matches the server's default site mode — PHP-FPM rows when
     *      shared_httpd is the default, otherwise the standalone PHP rows.
     *   2. SELECT container_types WHERE name LIKE '<family> X.Y%' AND active = 1.
     *   3. If no exact match, fall back to the highest-version active row in
     *      the same family 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;
        $family = $this->isSharedHttpdDefault() ? 'PHP-FPM' : 'PHP';

        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([$family . ' ' . $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 row in the chosen family. ORDER BY name DESC
        // works because the version suffix sorts lexicographically once the
        // prefix is fixed ('PHP 8.5' > 'PHP 8.4' > 'PHP 7.4' etc).
        $stmt = $this->pdo->prepare(
            "SELECT id, name, min_cpu, min_memory FROM whp.container_types
             WHERE name LIKE ? AND active = 1
             ORDER BY name DESC LIMIT 1"
        );
        $stmt->execute([$family . ' %']);
        $row = $stmt->fetch();
        if (!$row) {
            throw new Exception("No active $family 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),
        ];
    }

    /**
     * True iff the server is configured to serve customer sites through
     * shared-httpd by default, in which case new sites should be backed by
     * PHP-FPM (cac-fpm:phpXX) container types rather than the standalone
     * Apache+PHP cac:phpXX ones. Result is cached for the lifetime of the
     * importer instance.
     */
    private function isSharedHttpdDefault(): bool {
        if ($this->sharedHttpdDefault !== null) {
            return $this->sharedHttpdDefault;
        }
        $this->sharedHttpdDefault = false;
        $settingsFile = '/docker/whp/settings.json';
        if (!is_file($settingsFile)) {
            return false;
        }
        $raw = @file_get_contents($settingsFile);
        if ($raw === false) {
            return false;
        }
        $settings = json_decode($raw, true);
        $mode = $settings['shared_httpd']['default_mode'] ?? null;
        $this->sharedHttpdDefault = ($mode === 'shared_httpd');
        return $this->sharedHttpdDefault;
    }

    /**
     * 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. container_mode
        // tracks whether the site is served directly by its own Apache+PHP
        // container ('standalone') or proxied through shared-httpd to a
        // PHP-FPM container ('shared_httpd'). Mirror the server-wide default
        // — resolveContainerTypeId() already chose the matching family.
        $siteName = $domain . " (Imported from cPanel)";
        $containerMode = $this->isSharedHttpdDefault() ? 'shared_httpd' : 'standalone';

        $stmt = $this->pdo->prepare("
            INSERT INTO whp.sites (
                site_name,
                primary_domain_id,
                username,
                container_type_id,
                container_mode,
                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,
            $containerMode,
            $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',
                ];
                $this->dbRenameMap[$originalDbName] = $whpDbName;
                // Still record the tracking row even on re-import. The first run
                // may have crashed before reaching this code, or the row may
                // have been wiped by manual whp-side cleanup. Idempotent INSERT.
                $this->recordUserDatabase($whpDbName, $sqlFileSize !== false ? $sqlFileSize : null);
                return;
            }

            if ($created) {
                $this->log("Database created: " . $originalDbName . " -> " . $whpDbName);

                // Import SQL file. --disable-named-commands blocks the mysql
                // CLI's backslash-bang shell-out (and \system, \exec, \quit,
                // etc.) — without this flag a malicious cpmove .sql file
                // containing `\! rm -rf /etc` would shell out as the worker.
                // SQL statements still run normally.
                $command = "mysql --disable-named-commands "
                         . 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,
                    ];
                    $this->dbRenameMap[$originalDbName] = $whpDbName;
                    // Register the DB in the panel's tracking table so the
                    // Database Management UI sees an owner row for it. The
                    // panel currently filters by username prefix from
                    // information_schema for display, but consumers that join
                    // through whp.user_databases (size sweeps, future tooling)
                    // need the row. Failure is logged but non-fatal.
                    $this->recordUserDatabase($whpDbName, $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');
                // Still record the rename so replayMysqlGrants() can re-apply
                // grants even though the password is unknown to us; wp-config
                // rewrite for the affected sites will be skipped (no password).
                $this->userRenameMap[$originalUsername] = $whpUsername;
                $this->importedMysqlUsers[] = [
                    'original_name' => $originalUsername,
                    'whp_name'      => $whpUsername,
                    'status'        => 'existing',
                ];
            } elseif ($created) {
                $this->log("MySQL user created: " . $originalUsername . " -> " . $whpUsername);
                $this->userRenameMap[$originalUsername] = $whpUsername;
                $this->userPasswordMap[$originalUsername] = $password;
                $this->importedMysqlUsers[] = [
                    'original_name' => $originalUsername,
                    'whp_name'      => $whpUsername,
                    'status'        => 'created',
                ];
            } 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');
        }
    }
    
    /**
     * Provision docker containers for each newly-created site so the imported
     * customer can hit their sites immediately. Mirrors the logic of
     * api/sites.php's 'create_containers' action — same image-build,
     * env-var-build, volume-build, docker_api::create_container call,
     * site_containers INSERT, then haproxy_manager::configureSite.
     *
     * Each site is independent: a failure on one is logged and the next
     * site still gets a try. The "Create Containers" panel button stays
     * as the manual recovery path for whatever this couldn't finish.
     *
     * @param array $sites entries from createSites(): each has 'site_id'.
     */
    private function provisionSiteContainers(array $sites): void {
        if (!$sites) return;
        if (!class_exists('docker_api')) {
            require_once('/docker/whp/web/libs/docker-api.php');
        }
        if (!class_exists('haproxy_manager')) {
            require_once('/docker/whp/web/libs/haproxy_manager.php');
        }
        if (!isset($this->mysqlMgmt)) {
            if (!class_exists('mysqlmgmt')) {
                require_once('/docker/whp/web/libs/mysqlmgmt.php');
            }
            $this->mysqlMgmt = new mysqlmgmt();
        }
        $dockerApi = new docker_api();
        $haproxy   = new haproxy_manager();

        // UID lookup is per-username so we can do it once outside the loop.
        $uid = trim(shell_exec("id -u " . escapeshellarg($this->username)));
        if (!is_numeric($uid)) {
            $this->log("provisionSiteContainers: cannot resolve UID for {$this->username} — skipping all container provisioning", 'error');
            return;
        }

        $provisioned = 0;
        $failed = 0;
        foreach ($sites as $siteInfo) {
            $siteId = (int) ($siteInfo['site_id'] ?? 0);
            if (!$siteId) continue;
            try {
                $this->provisionOneSite($siteId, $uid, $dockerApi, $haproxy);
                $provisioned++;
            } catch (Exception $e) {
                $failed++;
                $this->log("provisionSiteContainers: site_id=$siteId failed: " . $e->getMessage(), 'error');
            }
        }
        $this->log("Container provisioning: provisioned=$provisioned, failed=$failed");
    }

    /**
     * Build the container config and create each container for one site, then
     * configure HAProxy. Lifted from api/sites.php case 'create_containers'.
     *
     * If docker_api::create_container reports an image-miss (the cpanel-mapped
     * container_type points at an image not in the registry — e.g. cac-fpm:php81
     * after older PHP versions were pruned from the registry), the site is
     * transparently fallen back to the newest active container_type in the
     * same family (PHP-FPM if shared_httpd, PHP otherwise) and the site row's
     * container_type_id is updated. The customer can downgrade later via the
     * panel if their app needs an older runtime.
     */
    private function provisionOneSite(int $siteId, string $uid, docker_api $dockerApi, haproxy_manager $haproxy): void {
        $site = $this->lookupSite($siteId);
        if (!$site) {
            throw new Exception("site row vanished");
        }

        $alreadyFellBack = false;
        $created = [];
        $i = 1;
        $maxSlot = (int) $site['container_count'];
        while ($i <= $maxSlot) {
            // Skip if site_containers already has a row for this slot — the
            // panel button may already have been clicked, or a retry of the
            // import shouldn't double-provision.
            $check = $this->pdo->prepare(
                "SELECT container_id FROM whp.site_containers WHERE site_id = ? AND container_number = ?"
            );
            $check->execute([$siteId, $i]);
            if ($check->fetch()) {
                $this->log("Container slot $i for site_id=$siteId already exists — skipping");
                $i++;
                continue;
            }

            $config = $this->buildContainerConfig($site, $uid, $i, $siteId);
            $result = $dockerApi->create_container($config);

            if (empty($result['success'])) {
                $err = (string) ($result['error'] ?? 'unknown');
                if (!$alreadyFellBack && $this->looksLikeImageMiss($err)) {
                    $fb = $this->findFallbackContainerType((int) $site['container_type_id']);
                    if ($fb) {
                        $this->log(
                            "Image {$config['image']} unavailable for site_id=$siteId — falling back to {$fb['name']} (id {$fb['id']}) and retrying",
                            'warning'
                        );
                        $upd = $this->pdo->prepare(
                            "UPDATE whp.sites SET container_type_id = ? WHERE id = ?"
                        );
                        $upd->execute([$fb['id'], $siteId]);
                        $site = $this->lookupSite($siteId);
                        if (!$site) {
                            throw new Exception("site row vanished mid-fallback");
                        }
                        $alreadyFellBack = true;
                        // Retry this same slot with the new image. $i not bumped.
                        continue;
                    }
                    // No fallback in this family — fall through to throw.
                }
                throw new Exception("docker create {$config['name']}: $err");
            }

            $ins = $this->pdo->prepare(
                "INSERT INTO whp.site_containers (site_id, container_id, container_number, status)
                 VALUES (?, ?, ?, 'running')"
            );
            $ins->execute([$siteId, $result['container_id'], $i]);
            $created[] = $config['name'];
            $i++;
        }

        if ($created) {
            $hp = $haproxy->configureSite($siteId, $site);
            if (empty($hp['success'])) {
                $this->log(
                    "HAProxy configureSite($siteId) failed: " . ($hp['error'] ?? 'unknown'),
                    'warning'
                );
            }
            $this->log("Provisioned containers for site_id=$siteId: " . implode(', ', $created));
        } else {
            $this->log("Provisioned no new containers for site_id=$siteId (all slots existed)");
        }
    }

    /**
     * Pull the full site + container_type + primary domain shape that
     * provisionOneSite needs. Reused on initial entry AND on container_type
     * fallback so the rebuilt config reflects the new image/env/mounts.
     */
    private function lookupSite(int $siteId): ?array {
        $stmt = $this->pdo->prepare("
            SELECT s.*,
                   COALESCE(sd.fqdn, d.domain_name) AS primary_domain,
                   ct.base_image,
                   ct.startup_env,
                   ct.mount_options,
                   ct.listen_port
            FROM whp.sites s
            JOIN whp.domains d ON s.primary_domain_id = d.id
            LEFT JOIN whp.site_domains sd ON s.id = sd.site_id AND sd.is_primary = 1
            JOIN whp.container_types ct ON s.container_type_id = ct.id
            WHERE s.id = ?
        ");
        $stmt->execute([$siteId]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    /**
     * Pick the newest active container_type in the family that matches the
     * server's default site mode. Used when the cpanel-mapped image isn't in
     * the registry. Returns null when there is no other type to fall back to.
     */
    private function findFallbackContainerType(int $excludeId): ?array {
        $family = $this->isSharedHttpdDefault() ? 'PHP-FPM' : 'PHP';
        $stmt = $this->pdo->prepare(
            "SELECT id, name, base_image FROM whp.container_types
             WHERE name LIKE ? AND active = 1 AND id <> ?
             ORDER BY name DESC LIMIT 1"
        );
        $stmt->execute([$family . ' %', $excludeId]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    /**
     * Heuristic match for docker create / pull errors that mean "the image
     * doesn't exist in the registry." We deliberately do NOT match on
     * generic errors like "port already in use" — those shouldn't downgrade
     * the customer's PHP runtime.
     */
    private function looksLikeImageMiss(string $err): bool {
        $patterns = [
            'no matching manifest',
            'manifest unknown',
            'image not found',
            'pull failed',
            'repository does not exist',
            'pull access denied',
        ];
        $low = strtolower($err);
        foreach ($patterns as $p) {
            if (strpos($low, $p) !== false) return true;
        }
        return false;
    }

    /**
     * Build the docker_api::create_container config payload for one site +
     * container slot. Pulled out of provisionOneSite so the fallback retry
     * can rebuild it from the updated $site row without code duplication.
     */
    private function buildContainerConfig(array $site, string $uid, int $i, int $siteId): array {
        $listenPort = (int) ($site['listen_port'] ?? 80);
        $primary    = (string) $site['primary_domain'];
        $safePrimary = str_starts_with($primary, '*.')
            ? str_replace('*.', 'wildcard.', $primary)
            : $primary;
        $containerName = $safePrimary . '-' . str_pad((string) $i, 2, '0', STR_PAD_LEFT);

        $env = [
            'WHP_USER=' . $this->username,
            'WHP_UID='  . $uid,
            'WHP_DOMAIN=' . $primary,
            'WHP_CONTAINER_NUMBER=' . $i,
        ];
        if (!empty($site['user_variables'])) {
            $uv = json_decode($site['user_variables'], true);
            if (is_array($uv)) {
                foreach ($uv as $k => $v) $env[] = $k . '=' . $v;
            }
        }
        if (!empty($site['startup_env'])) {
            $se = json_decode($site['startup_env'], true);
            if (is_array($se)) {
                foreach ($se as $k => $v) {
                    $v = $this->mysqlMgmt->replaceDynamicVariables(
                        $v, $this->username, $primary, $uid, $i, $listenPort
                    );
                    $env[] = $k . '=' . $v;
                }
            }
        }

        $volumes = [];
        if (!empty($site['mount_options'])) {
            $mo = json_decode($site['mount_options'], true);
            if (is_array($mo) && isset($mo['volumes'])) {
                foreach ($mo['volumes'] as $vol) {
                    $src = $this->mysqlMgmt->replaceDynamicVariables(
                        $vol['source'], $this->username, $primary, $uid, $i, $listenPort
                    );
                    $tgt = $this->mysqlMgmt->replaceDynamicVariables(
                        $vol['target'], $this->username, $primary, $uid, $i, $listenPort
                    );
                    $volumes[] = $src . ':' . $tgt;
                }
            }
        }

        return [
            'image'   => $site['base_image'],
            'name'    => $containerName,
            'labels'  => [
                'whp.user'             => $this->username,
                'whp.domain'           => $primary,
                'whp.site_id'          => (string) $siteId,
                'whp.container_number' => (string) $i,
            ],
            'env'     => $env,
            'volumes' => $volumes,
            'cpu'     => $site['cpu_per_container'],
            'memory'  => $site['memory_per_container'],
            'network' => 'client-net',
        ];
    }

    /**
     * Replay the original cPanel mysql.sql GRANTs against the renamed users
     * and renamed databases. Each entry in $backupData['mysql_grants'] now
     * carries the original (database, username) pair from parseMysqlGrants;
     * we translate both sides through the rename maps populated during
     * importDatabase() / createMysqlUser() and issue a single GRANT ALL per
     * pair.
     *
     * Skipped pairs (rename missing on either side) are logged as warnings
     * rather than fatal — typical causes are a global '*.*' grant the
     * importer doesn't try to replay, or a grant against a database that
     * failed to import earlier in the run.
     */
    private function replayMysqlGrants(array $backupData): void {
        $grants = $backupData['mysql_grants'] ?? [];
        if (!$grants) {
            return;
        }
        if (!isset($this->mysqlMgmt)) {
            if (!class_exists('mysqlmgmt')) {
                require_once('/docker/whp/web/libs/mysqlmgmt.php');
            }
            $this->mysqlMgmt = new mysqlmgmt();
        }
        $applied = 0;
        $skipped = 0;
        foreach ($grants as $g) {
            $origUser = $g['username'] ?? null;
            $origDb   = $g['database'] ?? null;
            if (!$origUser || !$origDb) {
                $skipped++;
                continue;
            }
            $whpUser = $this->userRenameMap[$origUser] ?? null;
            $whpDb   = $this->dbRenameMap[$origDb]   ?? null;
            if (!$whpUser || !$whpDb) {
                $this->log(
                    "GRANT skip: original user=$origUser db=$origDb not in rename map "
                    . "(user=" . ($whpUser ?: 'MISSING')
                    . ", db=" . ($whpDb ?: 'MISSING') . ")",
                    'warning'
                );
                $skipped++;
                continue;
            }
            // Hosts in cpmove are typically 'localhost'; mysqlmgmt creates the
            // WHP user at '@%' so customer-site containers can reach it on the
            // docker network. Match that host here — re-creating at the
            // cpmove host would require another CREATE USER round-trip.
            $userEsc = str_replace("'", "''", $whpUser);
            $dbEsc   = str_replace('`', '``', $whpDb);
            try {
                $this->pdo->exec("GRANT ALL PRIVILEGES ON `{$dbEsc}`.* TO '{$userEsc}'@'%'");
                $applied++;
                // Mirror the GRANT into whp.database_users so the panel/audit
                // surfaces sees the (user, db) pair the importer just created.
                // Privileges are the same 'ALL' we just issued; created_by is
                // the import owner, matching what the panel writes on manual
                // create_database_user.
                $this->recordDatabaseUser($whpUser, $whpDb, 'ALL');
            } catch (Exception $e) {
                $this->log("GRANT failed for $whpUser on $whpDb: " . $e->getMessage(), 'error');
                $skipped++;
            }
        }
        try {
            $this->pdo->exec("FLUSH PRIVILEGES");
        } catch (Exception $e) {
            $this->log("FLUSH PRIVILEGES failed: " . $e->getMessage(), 'warning');
        }
        $this->log("MySQL grants replayed: applied=$applied, skipped=$skipped");
    }

    /**
     * Walk each imported site's docroot and rewrite recognized DB connection
     * strings so apps point at the new MySQL container with renamed
     * credentials. Currently handles WordPress `wp-config.php`; the same
     * approach can extend to other apps by adding regex patterns.
     *
     * Each rewritten file is backed up to `<file>.bak.cpanel-import` so the
     * original cpmove values can be recovered if the rewrite misbehaves.
     */
    private function rewriteAppDbConfigs(): void {
        $rewrites = 0;
        $skipped  = 0;
        foreach ($this->siteGroups as $docRoot => $siteGroup) {
            $primaryDomain = $siteGroup['primary_domain'] ?? null;
            if (!$primaryDomain) continue;
            // importFiles copies into /docker/users/<user>/<domain>/public_html.
            // Search the whole per-domain tree to also catch nested apps
            // (e.g., /home/alsacorp/public_html/woomservice.com/wp-config.php).
            $base = '/docker/users/' . $this->username . '/' . $primaryDomain;
            if (!is_dir($base)) continue;
            // SECURITY: skip symlinks. cpmove tarballs preserve symlinks
            // verbatim, and RecursiveDirectoryIterator follows them by
            // default. Without this filter a symlink in the user's webroot
            // pointing at /etc/wp-config.php (or similar) would be picked
            // up here and `rewriteWpConfig()` would rewrite the symlink
            // target on the host. Same vulnerability class as the
            // removeDirectory() bug below.
            $it = new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS);
            $filter = new RecursiveCallbackFilterIterator($it, function ($current) {
                return !$current->isLink();
            });
            $iter = new RecursiveIteratorIterator($filter);
            foreach ($iter as $fileInfo) {
                if ($fileInfo->getFilename() !== 'wp-config.php') continue;
                // Defense-in-depth: also skip if the leaf is itself a symlink
                // (the filter catches dir-symlinks; this catches file-symlinks).
                if ($fileInfo->isLink()) continue;
                // Skip duplicator-pro / installer scratch copies (they're not
                // the live WP config and rewriting them is wasted work).
                $path = $fileInfo->getPathname();
                if (strpos($path, '/duplicator-pro/') !== false) {
                    continue;
                }
                if ($this->rewriteWpConfig($path)) {
                    $rewrites++;
                } else {
                    $skipped++;
                }
            }
        }
        $this->log("wp-config rewrite: applied=$rewrites, skipped=$skipped");
    }

    /**
     * Rewrite a single wp-config.php. Returns true on a successful rewrite,
     * false if the file's old DB_NAME/DB_USER aren't in the rename maps (so
     * the rewrite would corrupt the connection rather than fix it).
     */
    private function rewriteWpConfig(string $path): bool {
        $body = @file_get_contents($path);
        if ($body === false) return false;
        if (!preg_match("/define\(\s*['\"]DB_NAME['\"]\s*,\s*['\"]([^'\"]+)['\"]/", $body, $mName)) {
            return false;
        }
        if (!preg_match("/define\(\s*['\"]DB_USER['\"]\s*,\s*['\"]([^'\"]+)['\"]/", $body, $mUser)) {
            return false;
        }
        $oldDb   = $mName[1];
        $oldUser = $mUser[1];
        $newDb   = $this->dbRenameMap[$oldDb]     ?? null;
        $newUser = $this->userRenameMap[$oldUser] ?? null;
        $newPass = $this->userPasswordMap[$oldUser] ?? null;
        if (!$newDb || !$newUser || !$newPass) {
            $this->log(
                "wp-config skip $path: rename/password missing "
                . "(db=" . ($newDb ?: 'MISSING')
                . ", user=" . ($newUser ?: 'MISSING')
                . ", pass=" . ($newPass ? 'SET' : 'MISSING') . ")",
                'warning'
            );
            return false;
        }
        $bak = $path . '.bak.cpanel-import';
        if (!file_exists($bak)) {
            @file_put_contents($bak, $body);
        }
        $rewritten = $body;
        $rewritten = preg_replace(
            "/(['\"])DB_NAME\\1\\s*,\\s*['\"][^'\"]+['\"]/",
            "'DB_NAME', '" . addcslashes($newDb, "'\\") . "'",
            $rewritten
        );
        $rewritten = preg_replace(
            "/(['\"])DB_USER\\1\\s*,\\s*['\"][^'\"]+['\"]/",
            "'DB_USER', '" . addcslashes($newUser, "'\\") . "'",
            $rewritten
        );
        $rewritten = preg_replace(
            "/(['\"])DB_PASSWORD\\1\\s*,\\s*['\"][^'\"]*['\"]/",
            "'DB_PASSWORD', '" . addcslashes($newPass, "'\\") . "'",
            $rewritten
        );
        $rewritten = preg_replace(
            "/(['\"])DB_HOST\\1\\s*,\\s*['\"][^'\"]+['\"]/",
            "'DB_HOST', 'mysql'",
            $rewritten
        );
        if ($rewritten === null || $rewritten === $body) {
            return false;
        }
        if (@file_put_contents($path, $rewritten) === false) {
            $this->log("wp-config rewrite failed (write): $path", 'error');
            return false;
        }
        $this->log("wp-config rewritten: $path (db=$oldDb→$newDb, user=$oldUser→$newUser, host→mysql)");
        return true;
    }

    /**
     * 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. We MUST populate fqdn here: the unique key
            // is (site_id, domain_id, fqdn) and downstream consumers (Apache
            // vhost generator, haproxy-manager backend resolver, dashboard
            // domain list) all look up site_domains by fqdn. A row with
            // fqdn=NULL inserts cleanly but is functionally invisible — the
            // operator sees the domain "added" but it never serves traffic.
            // For wildcard inputs (e.g. *.cybercove.io) treat as non-wildcard
            // here; aliases handling in createWhpSite is the place that
            // distinguishes wildcards, and addon-domain calls don't carry
            // wildcards in practice.
            $stmt = $this->pdo->prepare("
                INSERT INTO whp.site_domains (site_id, domain_id, fqdn, is_primary, is_wildcard)
                VALUES (?, ?, ?, 0, 0)
                ON DUPLICATE KEY UPDATE is_primary = VALUES(is_primary)
            ");
            $stmt->execute([$siteId, $domainId, $domain]);

        } 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");
    }

    /**
     * Pre-extraction audit of the cpmove tarball for symlinks whose absolute
     * target points at a real host system path.
     *
     * Rationale: a cpmove tarball is wholly under the source-account's
     * control. Compromised cPanel accounts (e.g. WordPress installs carrying
     * the ALFA TEaM Shell) commonly bundle a symlink such as
     * `homedir/<docroot>/ALFA_DATA/alfasymlink/root -> /` so the webshell can
     * walk the source server's filesystem. When that tarball is unpacked on
     * WHP, the symlink resolves against the NEW host's `/`, `/etc`, `/usr`,
     * etc. — i.e. WHP's filesystem.
     *
     * The fixed removeDirectory() already refuses to traverse such symlinks
     * during cleanup (commit f7e4cbc). This scan layers a UX safety net on
     * top: surface the problem BEFORE extraction so the operator gets a
     * clear "your customer's site is compromised" error instead of a half-
     * imported account silently carrying a webshell into WHP.
     *
     * The scan uses `tar -tvf` (read-only, no extraction) to enumerate
     * symlinks. Each absolute-target symlink is classified:
     *
     *   DANGEROUS            target is `/`, OR target exists on this host
     *                        AND starts with a known-sensitive host prefix
     *                        (/etc, /usr, /bin, /sbin, /lib, /lib64, /boot,
     *                        /root, /var/lib, /var/log, /var/cache,
     *                        /var/spool). Aborts the import.
     *   UNCERTAIN            target exists on this host but is on no
     *                        dangerous prefix (e.g. /opt/...) — logged for
     *                        operator review, does not abort.
     *   BENIGN_USER_INTERNAL target is inside the user's own /home tree
     *                        (e.g. /home/<user>/...). Common cPanel pattern
     *                        for things like wp-content/db.php -> Query
     *                        Monitor's bundled copy. Not returned.
     *   BENIGN_DANGLING      target doesn't exist on this host as a real
     *                        path (e.g. /usr/local/apache/domlogs/<user>
     *                        on a WHP host where /usr/local/apache is
     *                        absent). The wormhole only opens if the link
     *                        resolves, so this is non-actionable. Not
     *                        returned.
     *
     * @param string $tarPath absolute path to the cpmove .tar.gz
     * @return array<int, array{type:string,archive_path:string,target:string,reason:string}>
     */
    private function scanTarballForDangerousSymlinks(string $tarPath): array {
        $findings = [];

        if (!is_file($tarPath) || !is_readable($tarPath)) {
            // Caller's validateBackupFile already errors; nothing to scan.
            return $findings;
        }

        // Prefixes whose contents we will NOT tolerate a symlink target
        // resolving into. Exact `/` is special-cased separately.
        $dangerousPrefixes = [
            '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64',
            '/boot', '/root',
            '/var/lib', '/var/log', '/var/cache', '/var/spool',
        ];

        // tar -tvf format (GNU): `lrwxrwxrwx user/group size YYYY-MM-DD HH:MM archive/path -> target`
        // We want only the lines whose mode starts with 'l' (symlink) AND
        // whose target (right of ' -> ') begins with '/'. Use -tvf, not -tf,
        // because -tf does not print the link target.
        $cmd = 'tar -tvf ' . escapeshellarg($tarPath) . ' 2>/dev/null';
        $fh = @popen($cmd, 'r');
        if (!$fh) {
            $this->log("scanTarballForDangerousSymlinks: failed to spawn tar -tvf on '$tarPath'", 'warning');
            return $findings;
        }

        // Discover the user's in-archive homedir prefix on the fly, so that
        // a symlink target of /home/<user>/... can be classified as
        // BENIGN_USER_INTERNAL. cPanel cpmove archives always carry a single
        // top-level dir named cpmove-<user>/, and the cpanel username equals
        // the linux-user the docroots reference. We grab that on first sight.
        $cpanelUsername = null;

        while (($line = fgets($fh)) !== false) {
            // Quick filter: symlinks only.
            if ($line === '' || $line[0] !== 'l') continue;
            // Split on the literal ' -> ' that tar -tvf emits between
            // archive path and target.
            $arrow = strpos($line, ' -> ');
            if ($arrow === false) continue;
            $left  = substr($line, 0, $arrow);
            $right = rtrim(substr($line, $arrow + 4), "\r\n");
            // The archive path is the last whitespace-delimited token on
            // the left. cPanel filenames don't contain runs of spaces in
            // practice, but to be safe we slice from the last 5-tuple of
            // mode/owner/size/date/time onward.
            //   lrwxrwxrwx darkside/darkside 0 2022-01-31 02:57 cpmove-darkside/.../root
            // We split on whitespace and assume the archive path is field 6+.
            $parts = preg_split('/\s+/', $left, 6);
            if (count($parts) < 6) continue;
            $archivePath = $parts[5];
            $target = $right;

            // Only absolute-target symlinks are interesting. In-archive
            // relative symlinks (e.g. .ea-php-cli.cache -> ea-php80) cannot
            // escape the extract dir under modern GNU tar.
            if ($target === '' || $target[0] !== '/') continue;

            // Cheap: derive cpanel username from the archive top-level dir
            // the first time we see one.
            if ($cpanelUsername === null) {
                if (preg_match('#^cpmove-([^/]+)/#', $archivePath, $m)) {
                    $cpanelUsername = $m[1];
                }
            }

            // Classify. Ordering matters:
            //   1. user-internal (-> /home/<user>/...) is always benign and
            //      we never want to surface it.
            //   2. exact `/` is always DANGEROUS (root always exists).
            //   3. target-doesn't-exist-on-this-host is BENIGN_DANGLING even
            //      if the target sits nominally under a dangerous prefix
            //      (e.g. /usr/local/apache/domlogs/<user> on a WHP host
            //      where /usr/local/apache doesn't exist). The threat only
            //      materializes if the path actually resolves to something.
            //   4. otherwise, a real on-host path under a dangerous prefix
            //      is DANGEROUS.
            //   5. a real on-host path not on the dangerous list is
            //      UNCERTAIN (operator review).
            $type = null;
            $reason = '';

            // (1) user-internal — target lives inside what will become the
            // customer's own /home/<user> tree on the destination. We accept
            // both the cpanel username discovered above AND the WHP
            // username — the cpmove may have been authored on a host where
            // the customer's account name differs.
            $userInternal = false;
            $usernames = [];
            if ($cpanelUsername !== null) $usernames[] = $cpanelUsername;
            if ($this->username !== null && $this->username !== '') $usernames[] = $this->username;
            foreach ($usernames as $u) {
                $prefix = '/home/' . $u . '/';
                if (strpos($target, $prefix) === 0 || $target === rtrim($prefix, '/')) {
                    $userInternal = true;
                    break;
                }
                // Multi-volume cPanel hosts use /home2, /home3, ...
                if (preg_match('#^/home\d+/' . preg_quote($u, '#') . '(/|$)#', $target)) {
                    $userInternal = true;
                    break;
                }
            }
            if ($userInternal) {
                // BENIGN_USER_INTERNAL — not returned.
                continue;
            }

            // (2) root.
            if ($target === '/') {
                $type = 'DANGEROUS';
                $reason = 'absolute target is root /';
            } else {
                // (3) does the target actually exist on this host? We use
                // file_exists() with the literal target since this scan
                // runs on the host that's about to import; a path that
                // exists here is what would resolve at extract+walk time.
                // BENIGN_DANGLING is silent.
                if (!file_exists($target)) {
                    continue;
                }
                // (4) on-host target under a dangerous prefix. Exact match
                // or prefix-with-trailing-slash so "/etc" and "/etc/passwd"
                // both match, but "/etcetera" does NOT.
                foreach ($dangerousPrefixes as $p) {
                    if ($target === $p || strpos($target, $p . '/') === 0) {
                        $type = 'DANGEROUS';
                        $reason = "absolute target resolves under host system path $p";
                        break;
                    }
                }
                // (5) on-host target, not on the dangerous list.
                if ($type === null) {
                    $type = 'UNCERTAIN';
                    $reason = 'absolute target exists on host but is not on the dangerous-prefix list';
                }
            }

            $findings[] = [
                'type'         => $type,
                'archive_path' => $archivePath,
                'target'       => $target,
                'reason'       => $reason,
            ];
        }
        pclose($fh);

        return $findings;
    }

    /**
     * Recursively remove directory.
     *
     * SECURITY: This is called against the extracted cpmove tree, whose
     * contents (symlinks included) are wholly controlled by the cPanel
     * account being imported. A previous implementation used is_dir() to
     * decide whether to recurse, which silently follows symlinks — if the
     * tarball contained `homedir/etc -> /etc`, removeDirectory walked into
     * /etc and unlink()'d every file. This wiped /etc on whp02 (2026-05-29)
     * and /usr/bin/* (2026-05-28) on prior imports.
     *
     * Two layers of defense now in place, either of which alone closes the
     * vector:
     *
     *   1. Containment check: refuse to operate on any path that is not
     *      under /tmp/cpanel_import_ (the importer's own tempdir). A future
     *      refactor that introduces a new caller can't accidentally point
     *      the recursive deleter at a real host directory.
     *
     *   2. Shell rm -rf via escapeshellarg. Unlike PHP's is_dir()+scandir,
     *      coreutils rm -rf does NOT traverse symlinks — it unlinks the
     *      symlink itself and the link target is untouched. This is the
     *      same approach WordPressManager.php uses for the same problem.
     *
     * Audit reference: /tmp/cpanel-import-audit.md (2026-05-29).
     */
    private function removeDirectory($dir) {
        $real = realpath($dir);
        // Containment: only remove the importer's own tempdir. We compare
        // both the raw path and the realpath() to defeat a symlink-as-root
        // attack where $dir itself is /tmp/cpanel_import_xxx pointing to /.
        if (strpos($dir, '/tmp/cpanel_import_') !== 0) {
            $this->log("removeDirectory refused: '$dir' not under /tmp/cpanel_import_", 'error');
            return;
        }
        if ($real !== false && strpos($real, '/tmp/cpanel_import_') !== 0) {
            $this->log("removeDirectory refused: '$dir' realpath '$real' escapes /tmp/cpanel_import_", 'error');
            return;
        }
        if (!is_dir($dir) && !is_link($dir)) return;
        // Shell rm -rf does NOT follow symlinks (it unlinks them). Belt-and-
        // suspenders with the containment check above.
        $cmd = 'rm -rf ' . escapeshellarg($dir);
        $out = [];
        $rc = 0;
        exec($cmd . ' 2>&1', $out, $rc);
        if ($rc !== 0) {
            $this->log("removeDirectory: rm -rf failed for '$dir' (rc=$rc): " . implode("\n", $out), 'warning');
        }
    }
    
    /**
     * 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%. Also flushes the in-memory $importLog buffer to the
     * row's log column so the panel's poller can render the live log — without
     * this, the worker only writes log at the very end (run-cpanel-import.php
     * passes $log on the final updateImportStatus call) and the user watches
     * an empty "Import Log" pane until completion. Stage-granularity flushes
     * are a reasonable middle ground vs. per-log()-call writes (~9 stages =
     * 9 UPDATEs across a multi-minute import). No-op if setImportId hasn't
     * been called.
     */
    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 = ?, log = ? WHERE import_id = ?"
            );
            $stmt->execute([
                max(0, min(100, $pct)),
                json_encode($this->importLog, JSON_UNESCAPED_SLASHES),
                $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 list of MySQL users successfully imported (or already-present
     * on a re-import). Each entry has original_name, whp_name, status.
     * Consumed by the summary writer so the per-import JSON exposes the
     * cpanel→whp user rename map — the backfill script that reconstructs
     * whp.database_users rows on existing imports reads this list back from
     * the summary file.
     */
    public function getImportedMysqlUsers(): array {
        return $this->importedMysqlUsers ?? [];
    }

    /**
     * Idempotent INSERT into whp.user_databases. The panel's Database
     * Management UI reads from information_schema for the listing today, but
     * downstream consumers (size sweeps, future ownership joins) join through
     * this tracking table. We mirror the column shape the canonical schema
     * defines: (database_name UNIQUE, owner_username, created_at, size_bytes).
     *
     * Failures are logged and swallowed — the MySQL-level DB exists and the
     * import is otherwise successful; we don't want a tracking-row write
     * failure to bubble up and kill a multi-site import.
     */
    private function recordUserDatabase(string $whpDbName, ?int $sizeBytes): void {
        try {
            $stmt = $this->pdo->prepare(
                "INSERT INTO whp.user_databases (database_name, owner_username, size_bytes)
                 VALUES (?, ?, ?)
                 ON DUPLICATE KEY UPDATE
                   owner_username = VALUES(owner_username),
                   size_bytes     = COALESCE(VALUES(size_bytes), size_bytes)"
            );
            $stmt->execute([$whpDbName, $this->username, $sizeBytes]);
        } catch (Exception $e) {
            $this->log(
                "recordUserDatabase: failed to record tracking row for db=$whpDbName "
                . "owner=" . $this->username . ": " . $e->getMessage(),
                'warning'
            );
        }
    }

    /**
     * Idempotent INSERT into whp.database_users. Column shape per the
     * canonical schema: (username, host, database_name, privileges, created_by).
     * The UNIQUE KEY is (username, host, database_name) so re-runs collapse to
     * a no-op via ON DUPLICATE KEY UPDATE.
     *
     * Host is always '%': WHP runs MySQL in a docker container reachable from
     * the host's docker-bridge IP, never from localhost — see the memory note
     * feedback_whp_mysql_user_host_pattern for the full background. Matching
     * cpmove's literal "localhost" host here would produce GRANTs that no
     * panel-side client can actually use.
     *
     * Failures are logged and swallowed for the same reason as
     * recordUserDatabase(): MySQL GRANT already landed, the import is fine.
     */
    private function recordDatabaseUser(string $whpUser, string $whpDb, string $privileges): void {
        try {
            $stmt = $this->pdo->prepare(
                "INSERT INTO whp.database_users (username, host, database_name, privileges, created_by)
                 VALUES (?, '%', ?, ?, ?)
                 ON DUPLICATE KEY UPDATE
                   privileges = VALUES(privileges),
                   created_by = VALUES(created_by)"
            );
            $stmt->execute([$whpUser, $whpDb, $privileges, $this->username]);
        } catch (Exception $e) {
            $this->log(
                "recordDatabaseUser: failed to record tracking row for user=$whpUser "
                . "db=$whpDb: " . $e->getMessage(),
                'warning'
            );
        }
    }

    /**
     * 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');
        }
    }
}