<?php

/**
 * CpanelImportSanitizationContainer
 *
 * Wraps the `cpanel-importer` sandbox container so CpanelBackupImporter doesn't
 * carry its own docker-CLI logic. Responsibilities:
 *
 *   - Decide whether to use the container at all (settings.json gate).
 *   - Decide whether the container is *usable* on this host (image pullable).
 *   - Run the container with the hardened flag-set documented in
 *     /workspace/cpanel-import-container-spec.md.
 *   - Read the per-import report.json the container drops in the output
 *     bind-mount and surface its verdict to the caller.
 *   - Hand back the path the panel should treat as the cleaned cpmove
 *     extraction tree, with the cleaned mysql/*.sql files merged in over
 *     the originals.
 *
 * The class is intentionally stateless across imports - every call site is
 * one-shot for a single (import_id, username, tarball). The shared state lives
 * in settings.json and on disk under /var/lib/whp/cpanel-importer-*.
 *
 * See also:
 *   - libs/CpanelBackupImporter.php (the caller; importBackup() routes through
 *     runSanitization() when execution_mode = 'sanitization-container').
 *   - scripts/updates/staging/setup-cpanel-importer-sandbox.sh (creates the
 *     host bind-mount dirs and pulls the image).
 *   - configs/whp-cpanel-import@.service (the systemd unit; ReadWritePaths
 *     already covers /var/lib/whp so our subdirs are writable from within
 *     the sandboxed worker).
 */
class CpanelImportSanitizationContainer
{
    /** Bind-mount roots on the host. */
    // Bind-mount roots for the sanitization container's host-side scratch.
    // Relocated from /var/lib/whp/* to /docker/whp/* in 2026.06 because the
    // host root filesystem is typically much smaller (<100GB) than /docker
    // (which is the dedicated data disk on every standard WHP install).
    // An alsacorp re-import on whp02 ran the root fs to 93% / 6.4G free
    // mid-extract; tar bailed with "No space left on device" while /docker
    // sat at 4% / 773G free.
    //
    // The staging migration script (2026.06's relocate-cpanel-importer-
    // roots.sh) symlinks the legacy /var/lib/whp paths to the new
    // /docker/whp paths so existing installs and any hard-coded operator
    // workflows referencing the old paths continue to work transparently.
    public const HOST_EXTRACT_ROOT    = '/docker/whp/cpanel-importer-extract';
    public const HOST_QUARANTINE_ROOT = '/docker/whp/cpanel-importer-quarantine';

    /** Default image when settings.json doesn't override. */
    public const DEFAULT_IMAGE = 'repo.anhonesthost.net/cloud-hosting-platform/cpanel-importer:latest';

    /** Where the container writes its merged report.json. */
    public const REPORT_FILE = 'report.json';

    /** @var array<string,mixed>|null cached settings.json read. */
    private static $settingsCache = null;

    /**
     * Read the cpanel_import.* block from /docker/whp/settings.json, with
     * defaults so callers never have to null-coalesce every field.
     *
     * @return array<string,mixed>
     */
    public static function getSettings(): array {
        if (self::$settingsCache !== null) return self::$settingsCache;

        $defaults = [
            'execution_mode'                          => 'direct',
            'image'                                   => self::DEFAULT_IMAGE,
            'image_pull_policy'                       => 'missing',
            'container_timeout_seconds'               => 7200,
            'memory_limit'                            => '2g',
            'cpu_limit'                               => '2',
            'fall_back_to_direct_on_image_unavailable' => true,
            'quarantine_retention_days'               => 90,
        ];

        $raw = [];
        if (is_file('/docker/whp/settings.json')) {
            $decoded = json_decode((string) @file_get_contents('/docker/whp/settings.json'), true);
            if (is_array($decoded) && isset($decoded['cpanel_import']) && is_array($decoded['cpanel_import'])) {
                $raw = $decoded['cpanel_import'];
            }
        }

        self::$settingsCache = array_replace($defaults, $raw);
        return self::$settingsCache;
    }

    /**
     * True iff the sanitization-container mode is selected in settings.json.
     * Caller still has to handle the fall-back-to-direct path if the image
     * isn't pullable on this host - that's runSanitization()'s job.
     */
    public static function isEnabled(): bool {
        $s = self::getSettings();
        return ($s['execution_mode'] ?? 'direct') === 'sanitization-container';
    }

    /**
     * Is the configured image present on this host (or pullable now)? Used
     * by runSanitization() to decide whether to fall back to direct mode.
     * Returns true if `docker image inspect` succeeds OR if a pull succeeds.
     */
    public static function isImageAvailable(string $image): bool {
        $cmd = 'docker image inspect ' . escapeshellarg($image) . ' >/dev/null 2>&1';
        $rc = 0;
        @exec($cmd, $_, $rc);
        if ($rc === 0) return true;

        // Try a pull (best-effort). Don't print docker output to the panel
        // log; caller will note the fallback decision either way.
        $cmd = 'docker pull ' . escapeshellarg($image) . ' >/dev/null 2>&1';
        $rc = 0;
        @exec($cmd, $_, $rc);
        return $rc === 0;
    }

    /**
     * Pre-create the host bind-mount directories the docker run needs. The
     * setup-cpanel-importer-sandbox.sh staging script does the same thing
     * on update; this is the panel-side fallback so the very first sandbox
     * import on a fresh server doesn't fail purely because the staging
     * script hasn't been run yet.
     */
    private static function ensureHostDirs(): void {
        foreach ([self::HOST_EXTRACT_ROOT, self::HOST_QUARANTINE_ROOT] as $dir) {
            if (!is_dir($dir)) {
                @mkdir($dir, 0750, true);
            }
        }
    }

    /**
     * Run the sandbox container against a single cpmove tarball.
     *
     * Return shape:
     *   [
     *     'success'          => bool,    // false = caller should fall back or fail
     *     'fell_back'        => bool,    // true = container path declined (image not pullable),
     *                                    //        caller should use legacy extract
     *     'fall_back_reason' => string,  // human-readable (only set when fell_back=true)
     *     'verdict'          => string,  // 'clean' | 'auto-cleaned' | 'refused' | 'failed'
     *     'report'           => array,   // parsed report.json (empty array on hard failure)
     *     'extract_path'     => string,  // path to hand to importBackup() as the new
     *                                    //   extractPath (empty on hard failure)
     *     'sanitized_dir'    => string,  // /var/lib/whp/cpanel-importer-extract/<id>
     *     'log_lines'        => array,   // captured container stdout/stderr lines for the
     *                                    //   panel's importLog
     *     'error'            => string,  // on success=false, human-readable failure
     *   ]
     *
     * @param string   $importId       the cpanel_imports.import_id
     * @param string   $username       the WHP target username
     * @param string   $tarballPath    absolute path to the cpmove .tar.gz on host
     * @param callable $logFn          fn(string $line, string $level='info'): void
     *                                 - pass $this->log() bound from the importer so
     *                                 container output flows into the same buffer
     *                                 the panel polling UI renders.
     * @param callable|null $flushFn   fn(): void — optional. If passed, called
     *                                 every ~3s during the container's streaming
     *                                 phase so the polling UI sees movement
     *                                 (without waiting for the next coarse
     *                                 reportProgress flush). Bind to the
     *                                 importer's flushLog() method.
     */
    public static function runSanitization(
        string $importId,
        string $username,
        string $tarballPath,
        callable $logFn,
        ?callable $flushFn = null
    ): array {
        $settings = self::getSettings();
        $image    = (string) ($settings['image'] ?? self::DEFAULT_IMAGE);

        $logFn("Sanitization container: settings.execution_mode=" . $settings['execution_mode']
             . " image=$image", 'info');

        // 1) Image-availability gate. If we can't pull and the operator
        //    opted in to fall-back, drop to direct mode loudly.
        if (!self::isImageAvailable($image)) {
            $reason = "Container image not available on this host: $image "
                    . "(`docker image inspect` failed and `docker pull` also failed). "
                    . "Check registry connectivity or pull the image manually.";
            if (!empty($settings['fall_back_to_direct_on_image_unavailable'])) {
                $logFn("SANITIZATION FALL-BACK: $reason - proceeding with direct in-process extract.", 'warning');
                return [
                    'success'          => false,
                    'fell_back'        => true,
                    'fall_back_reason' => $reason,
                    'verdict'          => '',
                    'report'           => [],
                    'extract_path'     => '',
                    'sanitized_dir'    => '',
                    'log_lines'        => [],
                    'error'            => '',
                ];
            }
            return [
                'success'          => false,
                'fell_back'        => false,
                'fall_back_reason' => '',
                'verdict'          => 'failed',
                'report'           => [],
                'extract_path'     => '',
                'sanitized_dir'    => '',
                'log_lines'        => [],
                'error'            => $reason,
            ];
        }

        // 2) Stage host paths the docker run mounts in.
        self::ensureHostDirs();
        // The container runs as --user 999:999 (whp-import inside the image)
        // for blast-radius reasons; both host bind-mount ROOTS need to be
        // writable by uid 999 because the container's entrypoint.sh does
        // `mkdir -p /host/sanitized/$IMPORT_ID` and `mkdir -p
        // /host/quarantine/$IMPORT_ID` from inside the namespace. If the
        // panel only chowns the per-import subdir but the ROOT stays as
        // root:root, the container's mkdir-of-the-parent path fails with
        // "Permission denied" before write_failure_report can even write a
        // partial report.json.
        //
        // setup.sh installs both roots as 999:999 0750 on fresh installs,
        // and the existing-server install script does the same. This block
        // is a defensive re-chown in case an operator-edited or
        // earlier-version filesystem still has the dirs at root:root.
        // Idempotent — re-chowning to the same owner is a no-op kernel-side.
        foreach ([self::HOST_EXTRACT_ROOT, self::HOST_QUARANTINE_ROOT] as $root) {
            if (!is_dir($root)) {
                @mkdir($root, 0750, true);
            }
            if (!@chown($root, 999)) {
                @chmod($root, 0777);
            } else {
                @chgrp($root, 999);
            }
        }

        $hostExtractDir    = self::HOST_EXTRACT_ROOT    . '/' . $importId;
        $hostQuarantineDir = self::HOST_QUARANTINE_ROOT . '/' . $importId;
        @mkdir($hostExtractDir,    0750, true);
        @mkdir($hostQuarantineDir, 0750, true);

        // Per-import subdirs also need to be 999:999 so the container can
        // write inside them. With the ROOT chown above this is technically
        // redundant for fresh dirs (default umask + parent-owned 999:999
        // → child becomes 999:something), but explicit chown defends
        // against operator-modified umask or non-default subdir creation.
        if (!@chown($hostExtractDir, 999) || !@chown($hostQuarantineDir, 999)) {
            @chmod($hostExtractDir,    0777);
            @chmod($hostQuarantineDir, 0777);
        } else {
            @chgrp($hostExtractDir,    999);
            @chgrp($hostQuarantineDir, 999);
        }

        // 3) Build the docker run command. Matches /workspace/cpanel-import-container-spec.md §2
        //    with the env-var calling convention the scaffold's entrypoint.sh expects.
        //    Differences vs. the dispatch prompt's invocation shape:
        //      - Container scaffold reads IMPORT_ID/IMPORT_USERNAME/IMPORT_BACKUP_FILE
        //        from env, not positional argv (see scripts/entrypoint.sh in the
        //        cpanel-importer repo).
        //      - Bind-mount layout uses /host/{backup,quarantine,sanitized} not
        //        /work/{input,output,quarantine}. Aligned with the entrypoint.
        $containerName  = 'whp-cpanel-importer-' . preg_replace('/[^A-Za-z0-9_.-]/', '_', $importId);
        $tarballMountTo = '/host/backup/' . basename($tarballPath);
        $memLimit       = (string) ($settings['memory_limit'] ?? '2g');
        $cpuLimit       = (string) ($settings['cpu_limit']    ?? '2');
        $timeoutSec     = (int)    ($settings['container_timeout_seconds'] ?? 7200);

        $argv = [
            'docker', 'run', '--rm',
            '--name',           $containerName,
            '--cap-drop=ALL',
            '--security-opt=no-new-privileges',
            '--read-only',
            // mode=1777 on /var/lib/clamav and /run is explicit because the
            // image's underlying dirs at those paths were chowned to whp-import
            // mode 0750 by the Dockerfile, and Docker's tmpfs inherits that
            // mode when no `mode=` is set — which is fine for /tmp (1777 in
            // the base image) but breaks /var/lib/clamav and /run, which
            // freshclam + clamscan and various PHP runtime bits need to write
            // to as uid 999. Setting 1777 explicitly matches /tmp's posture
            // (sticky bit + world rwx) and unbreaks freshclam.
            '--tmpfs', '/tmp:rw,nosuid,nodev,exec,size=4g,mode=1777',
            '--tmpfs', '/var/lib/clamav:rw,nosuid,nodev,size=512m,mode=1777',
            '--tmpfs', '/run:rw,nosuid,nodev,size=64m,mode=1777',
            '--user',           '999:999',
            '--memory',         $memLimit,
            '--memory-swap',    $memLimit,
            '--cpus',           $cpuLimit,
            '-v', $tarballPath       . ':' . $tarballMountTo . ':ro',
            '-v', $hostQuarantineDir . ':/host/quarantine:rw',
            // The container writes its output to /host/sanitized/<IMPORT_ID>/.
            // We mount the parent extract root as /host/sanitized so the
            // container's internal "$SANITIZED_DIR=/host/sanitized/$IMPORT_ID"
            // path resolves to $hostExtractDir on the host without an extra
            // nesting layer.
            '-v', self::HOST_EXTRACT_ROOT . ':/host/sanitized:rw',
            '-e', 'IMPORT_ID='          . $importId,
            '-e', 'IMPORT_USERNAME='    . $username,
            '-e', 'IMPORT_BACKUP_FILE=' . $tarballMountTo,
            '-e', 'CLAMAV_REFRESH=true',
            '--pull=' . (string) ($settings['image_pull_policy'] ?? 'missing'),
            $image,
        ];

        // Wrap in /usr/bin/timeout so a stuck container can't hang the worker
        // forever. The container itself has no inactivity watchdog; this is
        // the upper bound. SIGKILL after a 30s grace SIGTERM.
        $cmd = '/usr/bin/timeout --signal=TERM --kill-after=30s ' . $timeoutSec . 's '
             . implode(' ', array_map('escapeshellarg', $argv))
             . ' 2>&1';

        $logFn("Spawning sanitization container: $containerName (timeout=${timeoutSec}s)", 'info');

        // 4) Run the container, streaming stdout (with stderr merged in) into
        //    the import log. proc_open lets us emit each line as it arrives,
        //    which matters for multi-GB cpmoves that take many minutes - the
        //    operator sees movement in the panel's polling UI instead of an
        //    unmoving log for the duration.
        $logLines  = [];
        $exitCode  = 0;
        $startedAt = microtime(true);

        $descriptors = [1 => ['pipe', 'w']];
        $proc = @proc_open($cmd, $descriptors, $pipes);
        if (!is_resource($proc)) {
            $err = "Failed to spawn docker for sanitization container ($containerName).";
            $logFn($err, 'error');
            return [
                'success'          => false,
                'fell_back'        => false,
                'fall_back_reason' => '',
                'verdict'          => 'failed',
                'report'           => [],
                'extract_path'     => '',
                'sanitized_dir'    => $hostExtractDir,
                'log_lines'        => [],
                'error'            => $err,
            ];
        }

        // Non-blocking line reader so we can flush container output to the
        // log buffer as it arrives. Cap the per-read at 16KB to keep memory
        // bounded under runaway containers.
        //
        // logFn() writes into $this->importLog only — that buffer doesn't
        // hit the DB until the next reportProgress() fires, which only
        // happens on coarse stage boundaries. For a 5-minute container run
        // the operator would see a stuck progress bar + an empty Import
        // Log pane for the entire 5 minutes (looks indistinguishable from
        // a silent crash; surfaced during sandbox-mode rollout testing). The
        // flushFn is the importer's new flushLog() callback passed in by
        // maybeRunSanitizationContainer(); we call it every
        // $FLUSH_INTERVAL_SEC so the polling UI sees movement.
        $FLUSH_INTERVAL_SEC = 3;
        $lastFlushedAt = microtime(true);

        stream_set_blocking($pipes[1], false);
        $buf = '';
        while (true) {
            $status = proc_get_status($proc);
            $chunk  = fread($pipes[1], 16384);
            if ($chunk !== false && $chunk !== '') {
                $buf .= $chunk;
                while (($nl = strpos($buf, "\n")) !== false) {
                    $line = substr($buf, 0, $nl);
                    $buf  = substr($buf, $nl + 1);
                    if ($line !== '') {
                        $logLines[] = $line;
                        $logFn('[container] ' . $line, 'info');
                    }
                }
                if ($flushFn && (microtime(true) - $lastFlushedAt) >= $FLUSH_INTERVAL_SEC) {
                    $flushFn();
                    $lastFlushedAt = microtime(true);
                }
            } elseif (!$status['running']) {
                if ($buf !== '') {
                    $logLines[] = $buf;
                    $logFn('[container] ' . $buf, 'info');
                    $buf = '';
                }
                $exitCode = $status['exitcode'];
                break;
            } else {
                usleep(200000);
                if ($flushFn && (microtime(true) - $lastFlushedAt) >= $FLUSH_INTERVAL_SEC) {
                    $flushFn();
                    $lastFlushedAt = microtime(true);
                }
            }
        }
        // Final flush on exit so any tail lines are visible without waiting
        // for the next reportProgress().
        if ($flushFn) $flushFn();
        @fclose($pipes[1]);
        @proc_close($proc);

        $elapsed = (int) (microtime(true) - $startedAt);
        $logFn("Sanitization container exited rc=$exitCode after {$elapsed}s", 'info');

        // 5) Read the report. The container writes /host/sanitized/<id>/report.json
        //    on both success AND failure (write_failure_report in entrypoint.sh).
        $reportPath = $hostExtractDir . '/' . self::REPORT_FILE;
        $report     = [];
        if (is_file($reportPath)) {
            $decoded = json_decode((string) @file_get_contents($reportPath), true);
            if (is_array($decoded)) $report = $decoded;
        }

        // 6) Hard failures: non-zero exit or container declared failed.
        //
        // The report.json is the authoritative source. If the container
        // wrote status='completed' (its own self-attestation that all
        // stages ran cleanly), trust that — even when proc_get_status()
        // returned a weird exit code like -1.
        //
        // Why: PHP's proc_open / proc_get_status has a known quirk where
        // the cached exit_code field can be -1 if waitpid was already
        // reaped (race condition with how the streaming loop polls vs.
        // when the child actually exits). The alsacorp retry in 2026.06
        // surfaced this: the container completed every stage, wrote
        // report.json, logged "done — exited cleanly after 1755s", but
        // proc_get_status returned exitcode=-1. The panel marked the
        // run failed and the cleanup pass wiped the cleaned tree — even
        // though the container did its job correctly.
        $containerStatus = (string) ($report['status'] ?? '');
        $containerSelfDeclaresCompleted = ($containerStatus === 'completed');
        $hardFailure = $containerStatus === 'failed'
            || ($exitCode !== 0 && !$containerSelfDeclaresCompleted);

        if ($hardFailure) {
            $failedStage = (string) ($report['failed_stage'] ?? 'unknown');
            $reportErr   = $report['error'] ?? "container exited rc=$exitCode";
            if (!is_string($reportErr)) {
                $reportErr = json_encode($reportErr);
            }
            $msg = "Sanitization container failed (stage=$failedStage rc=$exitCode): $reportErr";
            $logFn($msg, 'error');
            return [
                'success'          => false,
                'fell_back'        => false,
                'fall_back_reason' => '',
                'verdict'          => 'failed',
                'report'           => $report,
                'extract_path'     => '',
                'sanitized_dir'    => $hostExtractDir,
                'log_lines'        => $logLines,
                'error'            => $msg,
            ];
        }

        // Log when we're trusting the report despite a non-zero exit code
        // so the operator sees the disagreement in the audit trail.
        if ($exitCode !== 0 && $containerSelfDeclaresCompleted) {
            $logFn(
                "Container reported rc=$exitCode but report.json status=completed; trusting the report"
                . " (likely a proc_get_status race — see entrypoint log for full container output).",
                'warning'
            );
        }

        // 7) Refusal verdict: container ran fine but said don't import this cpmove.
        //    Per spec: leave the sanitized dir + quarantine in place for review,
        //    mark the import failed, do NOT continue. The cpmove tarball is also
        //    untouched in userfiles/ (this code only reads, never deletes).
        $verdictFromReport = isset($report['summary_for_panel']['verdict'])
            ? (string) $report['summary_for_panel']['verdict']
            : '';
        $hardRefused = ($verdictFromReport === 'refused');

        if ($hardRefused) {
            $alertMsg = (string) ($report['summary_for_panel']['alert_message'] ?? 'Container refused the import.');
            $msg      = "Sanitization container REFUSED the import: $alertMsg";
            $logFn($msg, 'error');
            return [
                'success'          => false,
                'fell_back'        => false,
                'fall_back_reason' => '',
                'verdict'          => 'refused',
                'report'           => $report,
                'extract_path'     => '',
                'sanitized_dir'    => $hostExtractDir,
                'log_lines'        => $logLines,
                'error'            => $msg,
            ];
        }

        // 8) Clean / auto-cleaned: ready to hand back to the panel importer.
        $verdict = 'clean';
        if ((int) ($report['files_cleaned'] ?? 0) > 0 || (int) ($report['files_quarantined'] ?? 0) > 0) {
            $verdict = 'auto-cleaned';
        }

        // The container's output layout (per spec + scaffold):
        //   /host/sanitized/<importid>/extracted/<cpmove-username>/...   <- cleaned tree
        //   /host/sanitized/<importid>/mysql/<dbname>.sql                <- cleaned SQL dumps
        //   /host/sanitized/<importid>/report.json
        //
        // The existing CpanelBackupImporter expects mysql dumps at
        //   <extractPath>/<cpmove-username>/mysql/*.sql
        // because parseBackupStructure() reads $mainDir . '/mysql' (where
        // mainDir = first subdir under extractPath).
        //
        // The container writes cleaned dumps to a SEPARATE sibling dir. We
        // overlay them onto the extracted tree here so the rest of the
        // importer doesn't need to know about the container's layout. Any
        // *.sql.flagged file is left untouched in mysql/ - the importer's
        // existing getMysqlDatabases() globs *.sql, so .sql.flagged is
        // excluded from import by construction. Operator can rename to .sql
        // to retry a single DB later (matches spec §4 "import anyway" flow).
        $extractedDir = $hostExtractDir . '/extracted';
        if (!is_dir($extractedDir)) {
            $err = "Sanitization container reported success but produced no extracted/ dir at $extractedDir";
            $logFn($err, 'error');
            return [
                'success'          => false,
                'fell_back'        => false,
                'fall_back_reason' => '',
                'verdict'          => 'failed',
                'report'           => $report,
                'extract_path'     => '',
                'sanitized_dir'    => $hostExtractDir,
                'log_lines'        => $logLines,
                'error'            => $err,
            ];
        }

        self::overlayCleanedMysqlDumps($hostExtractDir, $extractedDir, $logFn);

        $alertMsg = (string) ($report['summary_for_panel']['alert_message'] ?? '');
        if ($alertMsg !== '') {
            $logFn("Sanitization summary: $alertMsg", 'info');
        }
        $logFn("Sanitization verdict=$verdict - handing extractPath=$extractedDir to importer", 'info');

        return [
            'success'          => true,
            'fell_back'        => false,
            'fall_back_reason' => '',
            'verdict'          => $verdict,
            'report'           => $report,
            'extract_path'     => $extractedDir,
            'sanitized_dir'    => $hostExtractDir,
            'log_lines'        => $logLines,
            'error'            => '',
        ];
    }

    /**
     * Copy each cleaned mysql/*.sql from <sanitizedDir>/mysql/ over the
     * corresponding original under <extractedDir>/<cpmove-user>/mysql/.
     *
     * The container's spec says "mysql/" is a flat dir keyed by dbname;
     * the importer's parseBackupStructure expects them under the cpmove-username
     * subdir. We bridge that here.
     *
     * @param string $sanitizedDir  /var/lib/whp/cpanel-importer-extract/<id>
     * @param string $extractedDir  $sanitizedDir/extracted
     */
    private static function overlayCleanedMysqlDumps(
        string $sanitizedDir,
        string $extractedDir,
        callable $logFn
    ): void {
        $cleanedMysqlDir = $sanitizedDir . '/mysql';
        if (!is_dir($cleanedMysqlDir)) {
            // Container chose not to emit a mysql/ subdir (cpmove has no DBs).
            return;
        }

        // Find the cpmove-<user> subdir under extracted/.
        $candidates = @glob($extractedDir . '/*', GLOB_ONLYDIR);
        if (!is_array($candidates) || empty($candidates)) {
            $logFn("overlayCleanedMysqlDumps: no cpmove root subdir under $extractedDir; skipping overlay", 'warning');
            return;
        }
        $cpmoveRoot = $candidates[0];
        $targetDir  = $cpmoveRoot . '/mysql';
        if (!is_dir($targetDir)) {
            @mkdir($targetDir, 0750, true);
        }

        $overlaid = 0;
        $flagged  = 0;
        foreach ((array) glob($cleanedMysqlDir . '/*') as $src) {
            if (!is_file($src)) continue;
            $base = basename($src);
            $dst  = $targetDir . '/' . $base;
            if (!@copy($src, $dst)) {
                $logFn("overlayCleanedMysqlDumps: failed to copy $src -> $dst", 'warning');
                continue;
            }
            if (substr($base, -strlen('.flagged')) === '.flagged') {
                $flagged++;
            } else {
                $overlaid++;
            }
        }

        if ($overlaid > 0 || $flagged > 0) {
            $logFn("overlayCleanedMysqlDumps: overlaid $overlaid cleaned SQL dump(s); $flagged flagged dump(s) staged for review", 'info');
        }
    }
}
