<?php
// cPanel Import AJAX Handler

// Set error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);

// Start output buffering to catch any unwanted output
ob_start();

require_once('../auto-prepend.php');

// Check if user is authenticated
if (!defined('AUTH_USER')) {
    ob_clean();
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Not authenticated']);
    exit();
}

require_once '../libs/CpanelBackupImporter.php';
require_once '../libs/mysqlmgmt.php';

// Clean any output that might have been generated
ob_clean();

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

// Initialize database connection
$MySQLMgmt = new mysqlmgmt();
$pdo = $MySQLMgmt->getMySQLConnection();
$pdo->exec("USE whp");

// Handle AJAX requests
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
    try {
        switch ($_POST['action']) {
            case 'get_backup_files':
                $targetUser = $_POST['target_user'] ?? null;
                $result = ['success' => true, 'files' => getAvailableBackupFiles($targetUser)];
                echo json_encode($result);
                break;
                
            case 'select_backup':
                $result = selectBackupFile();
                echo json_encode($result);
                break;
                
            case 'list_users':
                if (AUTH_USER !== 'root') {
                    echo json_encode(['success' => false, 'error' => 'Not authorized']);
                    break;
                }
                $result = listSystemUsers();
                echo json_encode($result);
                break;
                
            case 'start_import':
                $targetUser = $_POST['target_user'] ?? null;
                // skip_selection=1 (used by WHMCS / headless callers) bypasses
                // the analyze-then-confirm flow and goes straight to the legacy
                // fire-and-forget import. Default for the UI is 0 (selector
                // shown). For backwards compatibility with any existing caller
                // that doesn't pass the field, we ALSO default to 1 when
                // called via the external WHMCS surface — see the
                // X-WHMCS-Caller header gate below.
                $skipSelection = !empty($_POST['skip_selection']);
                if (!$skipSelection && isset($_SERVER['HTTP_X_WHMCS_CALLER'])) {
                    $skipSelection = true;
                }
                $result = startImport($_POST['backup_file'], $targetUser, $skipSelection);
                echo json_encode($result);
                break;

            case 'get_manifest':
                // Selector page calls this to render the site-group checklist.
                $result = getManifest($_POST['import_id'] ?? '');
                echo json_encode($result);
                break;

            case 'confirm_selection':
                // Selector page submits the chosen docroot keys here. We
                // persist them to cpanel_imports.selected_site_groups and
                // kick off the resume pass.
                $result = confirmSelection(
                    $_POST['import_id'] ?? '',
                    $_POST['selected_docroots'] ?? '[]'
                );
                echo json_encode($result);
                break;

            case 'get_import_status':
                $result = getImportStatus($_POST['import_id']);
                echo json_encode($result);
                break;

            default:
                echo json_encode(['success' => false, 'error' => 'Invalid action']);
        }
    } catch (Exception $e) {
        echo json_encode(['success' => false, 'error' => $e->getMessage()]);
    }
    exit;
}

function getAvailableBackupFiles($targetUser = null) {
    // Determine which user's directory to scan
    $username = AUTH_USER;
    if (AUTH_USER === 'root' && $targetUser) {
        $username = $targetUser;
    }
    if (!preg_match('/^[a-z_][a-z0-9_-]*$/i', $username)) {
        return [];
    }

    $userfilesDir = '/docker/users/' . $username . '/userfiles';
    $backupFiles = [];
    
    if (is_dir($userfilesDir)) {
        $files = glob($userfilesDir . '/*.{tar.gz,tgz}', GLOB_BRACE);
        foreach ($files as $file) {
            $backupFiles[] = [
                'filename' => basename($file),
                'path' => $file,
                'size' => filesize($file),
                'modified' => filemtime($file)
            ];
        }
        
        // Sort by modification time (newest first)
        usort($backupFiles, function($a, $b) {
            return $b['modified'] - $a['modified'];
        });
    }
    
    return $backupFiles;
}

function selectBackupFile() {
    if (!isset($_POST['backup_file'])) {
        return ['success' => false, 'error' => 'No backup file selected'];
    }

    // Determine which user's userfiles directory to look in.
    // Root may impersonate another user via target_user (matches get_backup_files).
    $username = AUTH_USER;
    if (AUTH_USER === 'root' && !empty($_POST['target_user'])) {
        $username = $_POST['target_user'];
    }
    if (!preg_match('/^[a-z_][a-z0-9_-]*$/i', $username)) {
        return ['success' => false, 'error' => 'Invalid target user'];
    }

    $selectedFile = $_POST['backup_file'];
    $userfilesDir = '/docker/users/' . $username . '/userfiles';
    $fullPath = $userfilesDir . '/' . basename($selectedFile);

    // Validate file exists and is in userfiles directory
    if (!file_exists($fullPath) || dirname($fullPath) !== $userfilesDir) {
        return ['success' => false, 'error' => 'Invalid backup file selected'];
    }
    
    // Validate file extension
    if (!preg_match('/\.(tar\.gz|tgz)$/i', $selectedFile)) {
        return ['success' => false, 'error' => 'Invalid file type. Must be a .tar.gz or .tgz file'];
    }
    
    return [
        'success' => true,
        'filename' => basename($selectedFile),
        'path' => $fullPath,
        'size' => filesize($fullPath)
    ];
}

function startImport($backupFilePath, $targetUser = null, $skipSelection = false) {
    global $pdo;

    // Determine the target username (root may impersonate)
    $username = AUTH_USER;
    if (AUTH_USER === 'root' && $targetUser) {
        $username = $targetUser;
    }
    if (!preg_match('/^[a-z_][a-z0-9_-]*$/i', $username)) {
        return ['success' => false, 'error' => 'Invalid target user'];
    }

    $emailTo = $_POST['email_summary_to'] ?? null;
    if ($emailTo !== null && !filter_var($emailTo, FILTER_VALIDATE_EMAIL)) {
        return ['success' => false, 'error' => 'Invalid email_summary_to'];
    }

    // Don't trust the client-supplied path: rebuild it from the basename and
    // the expected userfiles directory, then verify what we built actually
    // points at a real .tar.gz/.tgz file.
    $userfilesDir = '/docker/users/' . $username . '/userfiles';
    $backupFilePath = $userfilesDir . '/' . basename((string)$backupFilePath);

    if (!preg_match('/\.(tar\.gz|tgz)$/i', $backupFilePath)) {
        return ['success' => false, 'error' => 'Invalid file type. Must be a .tar.gz or .tgz file'];
    }
    if (!is_file($backupFilePath) || dirname($backupFilePath) !== $userfilesDir) {
        return ['success' => false, 'error' => 'Backup file not found'];
    }

    // Two-phase flow: default is to run the parse-only "analyze" pass first,
    // then redirect the operator to the selector. skipSelection=true (WHMCS,
    // headless callers, advanced UI option) bypasses analyze and goes
    // straight to the legacy fire-and-forget path.
    //
    // Mode encoding for the worker (see run-cpanel-import.php):
    //   "analyze"  → first pass: extract + parseBackupStructure + manifest
    //                write, then exits. Status flips to awaiting_selection
    //                when the manifest is on disk (the worker handles that).
    //   "resume"   → second pass: re-uses the analyze pass's extraction tree
    //                and applies the operator's docroot selection.
    //   "legacy"   → fire-and-forget end-to-end import, no selector.
    $mode = $skipSelection ? 'legacy' : 'analyze';

    // Create import record
    $importId = uniqid('import_');
    $stmt = $pdo->prepare("
        INSERT INTO cpanel_imports (import_id, username, backup_file, status, email_summary_to, created_at)
        VALUES (?, ?, ?, 'running', ?, NOW())
    ");
    $stmt->execute([$importId, $username, $backupFilePath, $emailTo]);
    
    // Start the worker as an isolated systemd unit instance. The unit's
    // ProtectSystem=strict + ProtectHome=read-only + scoped ReadWritePaths
    // enforce the blast radius at the kernel layer, so a future bug in the
    // importer cannot overwrite host paths the worker has no business
    // writing to (history: see commit f7e4cbc, audit at
    // /tmp/cpanel-import-audit.md, and configs/whp-cpanel-import@.service).
    //
    // The unit reads IMPORT_ID, IMPORT_USERNAME, IMPORT_BACKUP_FILE from a
    // per-import EnvironmentFile we drop at
    // /var/lib/whp/cpanel-imports/<import_id>.params before calling
    // `systemctl start whp-cpanel-import@<import_id>.service`.
    $logFile = "/var/log/cpanel-import-{$importId}.log";

    // Detect whether the unit has been installed. If the staging update
    // script has not run yet on this host (release tarball unpacked but
    // setup-cpanel-import-isolation.sh hasn't fired), fall back to the
    // legacy backgrounded invocation. The PHP-layer fixes in f7e4cbc still
    // protect that path.
    $unitInstalled = is_file('/etc/systemd/system/whp-cpanel-import@.service')
                  || is_file('/usr/lib/systemd/system/whp-cpanel-import@.service');

    if ($unitInstalled) {
        // Param-file body. systemd's EnvironmentFile parser is NOT a shell;
        // it accepts KEY=value with the value taken literally up to
        // end-of-line. The values flow into the unit's ExecStart= as
        // separate argv entries.
        //
        // $importId was just generated by uniqid('import_') (alphanumeric +
        // dot only - no newlines, no shell metacharacters). $username was
        // validated against /^[a-z_][a-z0-9_-]*$/i. $backupFilePath was
        // rebuilt from basename() under /docker/users/<u>/userfiles/ - no
        // traversal. So none of these can corrupt the env file.
        $paramsDir = '/var/lib/whp/cpanel-imports';
        if (!is_dir($paramsDir)) {
            @mkdir($paramsDir, 0700, true);
        }
        $paramsPath = $paramsDir . '/' . $importId . '.params';
        // IMPORT_MODE controls the worker's behaviour (analyze/resume/legacy).
        // The unit's ExecStart passes ${IMPORT_MODE} as the fourth argv.
        $paramsBody = "IMPORT_ID={$importId}\n"
                    . "IMPORT_USERNAME={$username}\n"
                    . "IMPORT_BACKUP_FILE={$backupFilePath}\n"
                    . "IMPORT_MODE={$mode}\n";
        if (file_put_contents($paramsPath, $paramsBody, LOCK_EX) === false) {
            error_log("cPanel import: failed to write params file {$paramsPath}");
            return ['success' => false, 'error' => 'Failed to stage import parameters'];
        }
        @chmod($paramsPath, 0600);

        $unitName = "whp-cpanel-import@{$importId}.service";
        // --no-block returns control immediately so the panel does not
        // wait for the long-running Type=oneshot worker to complete.
        $startCmd = "/usr/bin/systemctl start --no-block " . escapeshellarg($unitName);

        error_log("Starting cPanel import via systemd unit: {$unitName}");

        $output = [];
        $returnCode = 0;
        exec($startCmd . ' 2>&1', $output, $returnCode);

        if ($returnCode !== 0) {
            error_log("Failed to start {$unitName} (rc={$returnCode}): " . implode("\n", $output));
            return [
                'success' => false,
                'error'   => 'Failed to start import unit: ' . implode(' ', $output),
            ];
        }

        error_log("cPanel import unit started: {$unitName} (import_id {$importId})");
    } else {
        // Legacy fallback (pre-isolation path).
        $scriptPath = "/root/whp/scripts/run-cpanel-import.php";
        if (!file_exists($scriptPath)) {
            return ['success' => false, 'error' => 'Import script not found: ' . $scriptPath];
        }

        // Legacy invocation predates two-phase support — pass mode as the
        // fourth argv so run-cpanel-import.php knows which pass to run.
        $legacyCmd = "php " . escapeshellarg($scriptPath) . " " .
                     escapeshellarg($importId) . " " .
                     escapeshellarg($username) . " " .
                     escapeshellarg($backupFilePath) . " " .
                     escapeshellarg($mode) . " > " . escapeshellarg($logFile) . " 2>&1 &";

        error_log("cPanel import: systemd unit not installed; using legacy invocation: {$legacyCmd}");

        $output = [];
        $returnCode = 0;
        exec($legacyCmd, $output, $returnCode);

        if ($returnCode !== 0) {
            error_log("Failed to start cPanel import process (legacy path). Return code: {$returnCode}");
            return ['success' => false, 'error' => 'Failed to start import process'];
        }

        error_log("cPanel import process started successfully (legacy path) for import ID: {$importId}");
    }
    
    return [
        'success' => true,
        'import_id' => $importId,
        'log_file' => $logFile
    ];
}

/**
 * Read the analyze-phase manifest for an import. Used by the selector page
 * to render the site-group checklist. Scoped by ownership the same way
 * getImportStatus does (root can read any; user can only read their own).
 */
function getManifest($importId) {
    global $pdo;

    if (!preg_match('/^import_[A-Za-z0-9]+$/', $importId)) {
        return ['success' => false, 'error' => 'Invalid import_id'];
    }

    // Ownership check.
    if (defined('AUTH_USER') && AUTH_USER === 'root') {
        $stmt = $pdo->prepare("SELECT username, status FROM cpanel_imports WHERE import_id = ?");
        $stmt->execute([$importId]);
    } else {
        $stmt = $pdo->prepare("SELECT username, status FROM cpanel_imports WHERE import_id = ? AND username = ?");
        $stmt->execute([$importId, AUTH_USER]);
    }
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!$row) {
        return ['success' => false, 'error' => 'Import not found'];
    }

    $path = '/var/lib/whp/cpanel-imports/' . $importId . '.manifest.json';
    if (!is_file($path) || !is_readable($path)) {
        return [
            'success' => false,
            'error'   => 'Manifest not yet available',
            'status'  => $row['status'],
        ];
    }
    $raw = @file_get_contents($path);
    if ($raw === false) {
        return ['success' => false, 'error' => 'Failed to read manifest'];
    }
    $manifest = json_decode($raw, true);
    if (!is_array($manifest)) {
        return ['success' => false, 'error' => 'Manifest is not valid JSON'];
    }
    return [
        'success'  => true,
        'manifest' => $manifest,
        'status'   => $row['status'],
    ];
}

/**
 * Persist the operator's docroot selection and kick off the resume pass.
 *
 * $selectedDocrootsJson is a JSON-encoded array of docroot strings (the
 * keys of the manifest's site_groups[].docroot field). We tolerate empty
 * lists (the worker will create no sites and the import will land at
 * completed with zero sites — surfaced loudly in the log + results page).
 */
function confirmSelection($importId, $selectedDocrootsJson) {
    global $pdo;

    if (!preg_match('/^import_[A-Za-z0-9]+$/', $importId)) {
        return ['success' => false, 'error' => 'Invalid import_id'];
    }

    // Ownership + state check. Only awaiting_selection rows are eligible
    // (a user re-submitting the form for a row that's already running or
    // completed is almost certainly a stale-tab bug; refuse loudly rather
    // than re-kick the worker).
    if (defined('AUTH_USER') && AUTH_USER === 'root') {
        $stmt = $pdo->prepare("SELECT username, backup_file, status FROM cpanel_imports WHERE import_id = ?");
        $stmt->execute([$importId]);
    } else {
        $stmt = $pdo->prepare("SELECT username, backup_file, status FROM cpanel_imports WHERE import_id = ? AND username = ?");
        $stmt->execute([$importId, AUTH_USER]);
    }
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!$row) {
        return ['success' => false, 'error' => 'Import not found'];
    }
    if ($row['status'] !== 'awaiting_selection') {
        return [
            'success' => false,
            'error'   => 'Import is not awaiting selection (current status: ' . $row['status'] . ')',
        ];
    }

    $selected = json_decode((string) $selectedDocrootsJson, true);
    if (!is_array($selected)) {
        return ['success' => false, 'error' => 'selected_docroots must be a JSON array'];
    }
    // Drop non-strings + dedup.
    $clean = [];
    foreach ($selected as $d) {
        if (is_string($d) && $d !== '') $clean[$d] = true;
    }
    $selected = array_keys($clean);

    // Persist selection.
    $stmt = $pdo->prepare(
        "UPDATE cpanel_imports SET selected_site_groups = ?, status = 'running', progress = 40
         WHERE import_id = ?"
    );
    $stmt->execute([json_encode($selected, JSON_UNESCAPED_SLASHES), $importId]);

    // Kick off the resume pass. Same systemd unit, mode=resume. The unit
    // is single-instance-per-import-id; the previous (analyze) invocation
    // has already exited because the worker returns after writing the
    // manifest. So this is a fresh start, not a stack.
    $username = $row['username'];
    $backupFilePath = $row['backup_file'];
    $launch = launchWorker($importId, $username, $backupFilePath, 'resume');
    if (!$launch['success']) {
        // Don't leave the row in a half-state; revert to awaiting_selection
        // so the operator can retry without re-running analyze.
        $stmt = $pdo->prepare(
            "UPDATE cpanel_imports SET status = 'awaiting_selection' WHERE import_id = ?"
        );
        $stmt->execute([$importId]);
        return $launch;
    }

    return [
        'success'   => true,
        'import_id' => $importId,
        'selected_count' => count($selected),
    ];
}

/**
 * Common worker-launcher: either systemd unit (if installed) or the legacy
 * backgrounded php invocation. Used by both startImport() (mode=analyze /
 * legacy) and confirmSelection() (mode=resume) so the two paths can't drift.
 */
function launchWorker(string $importId, string $username, string $backupFilePath, string $mode): array {
    $unitInstalled = is_file('/etc/systemd/system/whp-cpanel-import@.service')
                  || is_file('/usr/lib/systemd/system/whp-cpanel-import@.service');

    if ($unitInstalled) {
        $paramsDir = '/var/lib/whp/cpanel-imports';
        if (!is_dir($paramsDir)) {
            @mkdir($paramsDir, 0700, true);
        }
        $paramsPath = $paramsDir . '/' . $importId . '.params';
        $paramsBody = "IMPORT_ID={$importId}\n"
                    . "IMPORT_USERNAME={$username}\n"
                    . "IMPORT_BACKUP_FILE={$backupFilePath}\n"
                    . "IMPORT_MODE={$mode}\n";
        if (file_put_contents($paramsPath, $paramsBody, LOCK_EX) === false) {
            error_log("cPanel import: failed to write params file {$paramsPath}");
            return ['success' => false, 'error' => 'Failed to stage import parameters'];
        }
        @chmod($paramsPath, 0600);

        $unitName = "whp-cpanel-import@{$importId}.service";
        $startCmd = "/usr/bin/systemctl start --no-block " . escapeshellarg($unitName);
        $output = []; $returnCode = 0;
        exec($startCmd . ' 2>&1', $output, $returnCode);
        if ($returnCode !== 0) {
            error_log("Failed to start {$unitName} (rc={$returnCode}): " . implode("\n", $output));
            return ['success' => false, 'error' => 'Failed to start import unit: ' . implode(' ', $output)];
        }
        return ['success' => true];
    }

    // Legacy fallback.
    $scriptPath = "/root/whp/scripts/run-cpanel-import.php";
    if (!file_exists($scriptPath)) {
        return ['success' => false, 'error' => 'Import script not found: ' . $scriptPath];
    }
    $logFile = "/var/log/cpanel-import-{$importId}.log";
    $legacyCmd = "php " . escapeshellarg($scriptPath) . " " .
                 escapeshellarg($importId) . " " .
                 escapeshellarg($username) . " " .
                 escapeshellarg($backupFilePath) . " " .
                 escapeshellarg($mode) . " > " . escapeshellarg($logFile) . " 2>&1 &";
    $output = []; $returnCode = 0;
    exec($legacyCmd, $output, $returnCode);
    if ($returnCode !== 0) {
        error_log("Failed to start cPanel import process (legacy path). Return code: {$returnCode}");
        return ['success' => false, 'error' => 'Failed to start import process'];
    }
    return ['success' => true];
}

function getImportStatus($importId) {
    global $pdo;

    // Root acts on behalf of any target user (the start_import flow already
    // accepts target_user when AUTH_USER === 'root'). Scoping by AUTH_USER
    // here would always return "Import not found" for those imports, the
    // panel's polling JS silently swallows non-success responses, and the
    // progress bar + log pane stay stuck at "Starting Import..." for the
    // whole run. Drop the username filter for root; otherwise restrict to
    // imports the calling user owns.
    if (defined('AUTH_USER') && AUTH_USER === 'root') {
        $stmt = $pdo->prepare("SELECT * FROM cpanel_imports WHERE import_id = ?");
        $stmt->execute([$importId]);
    } else {
        $stmt = $pdo->prepare("SELECT * FROM cpanel_imports WHERE import_id = ? AND username = ?");
        $stmt->execute([$importId, AUTH_USER]);
    }
    $import = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$import) {
        return ['success' => false, 'error' => 'Import not found'];
    }
    
    return [
        'success' => true,
        'status' => $import['status'],
        'progress' => $import['progress'] ?? 0,
        'log' => $import['log'] ? json_decode($import['log'], true) : [],
        'error' => $import['error_message'],
        'completed_at' => $import['completed_at']
    ];
}

/**
 * List system users for root user selection
 */
function listSystemUsers() {
    $users = [];
    
    // Get users from /etc/passwd who have home directories in /docker/users/
    $passwdContent = file_get_contents('/etc/passwd');
    $lines = explode("\n", $passwdContent);
    
    foreach ($lines as $line) {
        $parts = explode(':', $line);
        if (count($parts) >= 6) {
            $username = $parts[0];
            $homeDir = $parts[5];
            
            // Skip system users
            if (in_array($username, ['root', 'bin', 'daemon', 'adm', 'lp', 'sync', 'shutdown', 'halt', 'mail', 'operator', 'games', 'ftp', 'nobody', 'systemd-network', 'dbus', 'polkitd', 'sshd', 'postfix', 'chrony', 'mysql', 'apache', 'nginx', 'named', 'rpc', 'rpcuser', 'nfsnobody'])) {
                continue;
            }
            
            // Check if user has a directory in /docker/users/
            if (is_dir("/docker/users/$username")) {
                $users[] = $username;
            }
        }
    }
    
    sort($users);
    
    return [
        'success' => true,
        'users' => $users
    ];
}
?>