<?php
// Multi-site bulk management endpoint.
//
// Async-fork architecture (2026.05 follow-up to the inline version that
// shipped in 2026.05.196):
//   - POST with `bulk_action=<action>` writes a job spec to
//     /docker/whp/sql/bulk-jobs/<job_id>.json, kicks off
//     `whp-bulk-sites@<job_id>.service` (which runs
//     /docker/whp/web/scripts/run-bulk-sites.php), and returns
//     {status:"ok", job_id, accepted:true} immediately (HTTP 202).
//   - GET with `bulk_action=status&job_id=<id>` (or POST bulk_action=get_status)
//     returns the parsed JSONL stream — same shape the prior inline path
//     emitted, so the bulkResultsModal polling in pages/sites.php works
//     unchanged.
//   - POST with `bulk_action=cancel_job&job_id=<id>` sends SIGTERM to the
//     unit via `systemctl stop`. The worker's pcntl_signal handler writes
//     a final "cancelled" line and exits cleanly.
//
// Why systemd: matches whp-cpanel-import@.service pattern (commit f7e4cbc).
// Bulk recreates / SSL renews on hundreds of sites can run 10+ minutes;
// blocking the HTTP request was always going to overflow php-fpm and the
// upstream proxy idle timeouts. The kernel-level sandbox is a bonus.
//
// Supported actions (handlers live in scripts/run-bulk-sites.php):
//   - recreate_containers           — bulk wrapper around recreate_all_containers
//   - delete_sites                  — bulk wrapper around delete_site
//   - change_container_type         — re-point sites at a new container_type_id and recreate
//   - change_resources              — set cpu_per_container / memory_per_container
//   - toggle_auto_recreate          — flip auto_recreate_on_update
//   - ssl_renew                     — per-site ssl_manager::renew_ssl
//   - add_additional_domain         — link an existing domain to each selected site
//   - status                        — GET-only: poll the JSONL stream
//   - get_status                    — POST alias of status; returns last N lines
//   - cancel_job                    — POST: systemctl stop the worker unit
//
// Permissions:
//   - Non-root: only sites where sites.username = AUTH_USER are touched.
//     The worker re-runs the ownership check against the DB; any site_id
//     outside scope is emitted as 'skipped' with reason 'access_denied'.
//   - Root: any site, but each site is still validated by id (no wildcards,
//     no "all sites" path that bypasses the explicit id list).

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

check_api_permission('sites');

require_once('/docker/whp/web/libs/mysqlmgmt.php');

if ($_SERVER['REQUEST_METHOD'] !== 'POST' && !isset($_GET['bulk_action'])) {
    http_response_code(405);
    echo json_encode(['status' => 'error', 'message' => 'method_not_allowed']);
    exit;
}

// Status polls are GET-only and need to skip CSRF (the polling JS doesn't
// re-mint the token per tick and the action is read-only).
$is_status_poll = (
    $_SERVER['REQUEST_METHOD'] === 'GET'
    && isset($_GET['bulk_action'])
    && $_GET['bulk_action'] === 'status'
);

if (!$is_status_poll && !validate_csrf_token()) {
    http_response_code(403);
    echo json_encode(['status' => 'error', 'message' => 'csrf']);
    exit;
}

$MySQLMgmt = new mysqlmgmt();
$db = $MySQLMgmt->getMySQLConnection();
if (!$db) {
    http_response_code(500);
    echo json_encode(['status' => 'error', 'message' => 'Database connection failed']);
    exit;
}

$is_root = (defined('AUTH_USER') && AUTH_USER === 'root');
$actor   = defined('AUTH_USER') ? AUTH_USER : 'unknown';

// ---------------- Helpers ----------------

/**
 * Pull all sites by id, enforcing per-row ownership. Used pre-spawn to
 * report skipped (not-found / access-denied) rows in the synchronous
 * 202 response so the UI shows them immediately. The worker re-runs the
 * same check against the live DB row.
 * Returns [ [site row...], [skipped_id => reason, ...] ]
 */
function whp_bulk_load_sites(PDO $db, array $site_ids, bool $is_root): array {
    $site_ids = array_values(array_unique(array_filter(array_map('intval', $site_ids), fn($i) => $i > 0)));
    if (empty($site_ids)) return [[], []];

    $skipped = [];
    $sites_by_id = [];

    $placeholders = implode(',', array_fill(0, count($site_ids), '?'));
    $sql = "
        SELECT s.id, s.username
        FROM whp.sites s
        WHERE s.id IN ($placeholders)
    ";
    $stmt = $db->prepare($sql);
    $stmt->execute($site_ids);
    foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
        $sites_by_id[(int)$row['id']] = $row;
    }

    $ordered = [];
    foreach ($site_ids as $sid) {
        if (!isset($sites_by_id[$sid])) {
            $skipped[$sid] = 'not_found';
            continue;
        }
        $row = $sites_by_id[$sid];
        if (!$is_root && $row['username'] !== AUTH_USER) {
            $skipped[$sid] = 'access_denied';
            continue;
        }
        $ordered[] = $row;
    }
    return [$ordered, $skipped];
}

function whp_bulk_result(int $site_id, string $action, string $status, string $message, array $extra = []): array {
    return array_merge([
        'site_id' => $site_id,
        'action'  => $action,
        'status'  => $status,
        'message' => $message,
    ], $extra);
}

function whp_bulk_job_dir(): string {
    $dir = '/docker/whp/sql/bulk-jobs';
    if (!is_dir($dir)) {
        // Best-effort mkdir; the staging update script
        // (scripts/updates/.../setup-bulk-sites-worker.sh) is the canonical
        // creator. If the dir doesn't exist and we can't make it, the
        // spec_path write below will fail with a clear error.
        @mkdir($dir, 0750, true);
    }
    return $dir;
}

function whp_bulk_new_job_id(string $action): string {
    // Action name as prefix keeps job_ids self-describing in `ls`, logs,
    // and the systemd unit instance name. The 8-char random hex makes
    // collisions astronomically unlikely even under burst load.
    return $action . '-' . date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
}

function whp_bulk_job_path(string $job_id): string {
    $job_id = preg_replace('/[^A-Za-z0-9_\-]/', '', $job_id);
    return whp_bulk_job_dir() . '/' . $job_id . '.jsonl';
}

function whp_bulk_spec_path(string $job_id): string {
    $job_id = preg_replace('/[^A-Za-z0-9_\-]/', '', $job_id);
    return whp_bulk_job_dir() . '/' . $job_id . '.json';
}

/**
 * Common worker-launcher: systemd unit if installed, legacy backgrounded
 * php fallback otherwise. Returns ['success' => bool, 'error' => string?].
 *
 * Mirrors the launchWorker() helper in api/cpanel-import-ajax.php — the
 * fallback exists so a release tarball that lands on disk before the
 * setup-bulk-sites-worker.sh staging script runs can still execute jobs
 * (just without the kernel-level sandbox).
 */
function whp_bulk_launch_worker(string $job_id): array {
    $unitInstalled = is_file('/etc/systemd/system/whp-bulk-sites@.service')
                  || is_file('/usr/lib/systemd/system/whp-bulk-sites@.service');

    if ($unitInstalled) {
        $paramsDir = '/var/lib/whp/bulk-sites';
        if (!is_dir($paramsDir)) {
            @mkdir($paramsDir, 0700, true);
        }
        $paramsPath = $paramsDir . '/' . $job_id . '.params';
        // EnvironmentFile parsing is NOT a shell; KEY=value taken literally.
        // $job_id already passed the [A-Za-z0-9_\-] gate so no newlines /
        // shell metas can leak into the env file.
        $paramsBody = "BULK_JOB_ID={$job_id}\n";
        if (file_put_contents($paramsPath, $paramsBody, LOCK_EX) === false) {
            error_log("sites-bulk: failed to write params file {$paramsPath}");
            return ['success' => false, 'error' => 'failed_to_stage_params'];
        }
        @chmod($paramsPath, 0600);

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

    // Legacy fallback (pre-staging-script path). Backgrounded php with
    // stdout/stderr to a per-job log file matching the systemd unit's
    // StandardOutput= path so operators can tail the same file regardless
    // of which spawn mode fired.
    $scriptPath = "/docker/whp/web/scripts/run-bulk-sites.php";
    if (!file_exists($scriptPath)) {
        // Fall back to the source-repo path on the off-chance the rsync hasn't
        // happened yet (release tarball unpacked but update.sh sync-step
        // hasn't run). Same convention as cpanel-import-ajax.php.
        $scriptPath = "/root/whp/scripts/run-bulk-sites.php";
        if (!file_exists($scriptPath)) {
            return ['success' => false, 'error' => 'worker_script_not_found'];
        }
    }
    $logFile = "/var/log/whp-bulk-sites-{$job_id}.log";
    $legacyCmd = "php " . escapeshellarg($scriptPath) . " " .
                 escapeshellarg($job_id) . " > " . escapeshellarg($logFile) . " 2>&1 &";
    $output = []; $returnCode = 0;
    exec($legacyCmd, $output, $returnCode);
    if ($returnCode !== 0) {
        error_log("sites-bulk: failed to start worker (legacy path) rc={$returnCode}");
        return ['success' => false, 'error' => 'legacy_spawn_failed'];
    }
    return ['success' => true, 'mode' => 'legacy'];
}

/**
 * Read and parse the JSONL stream for a job. Returns the same shape the
 * previous inline path emitted so the polling JS in pages/sites.php works
 * unchanged: ['status'=>'ok'|'pending'|'error', 'done'=>bool,
 *             'results'=>[per-site rows], 'summary'=>[ok,failed,skipped,total]?,
 *             'cancelled'=>bool?].
 *
 * $tail (default null) caps the number of trailing rows returned, for the
 * POST get_status action that doesn't need the full history on every poll.
 */
function whp_bulk_read_status(string $job_id, ?int $tail = null): array {
    $path = whp_bulk_job_path($job_id);
    if (!file_exists($path)) {
        return ['status' => 'pending', 'results' => [], 'done' => false];
    }
    $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    if ($lines === false) {
        return ['status' => 'error', 'message' => 'failed_to_read_jsonl', 'results' => [], 'done' => false];
    }

    $results   = [];
    $done      = false;
    $cancelled = false;
    $summary   = null;
    $error     = null;
    foreach ($lines as $line) {
        $row = json_decode($line, true);
        if (!is_array($row)) continue;
        if (!empty($row['__done__'])) {
            $done      = true;
            $cancelled = !empty($row['cancelled']);
            $summary   = $row['summary'] ?? null;
            if (isset($row['error'])) $error = $row['error'];
            continue;
        }
        // Skip sentinel rows the worker uses for its own state machine
        // (queued / cancelling). They don't represent a site result and
        // the UI shouldn't render them in the results table.
        if (!empty($row['__queued__']) || !empty($row['__cancelling__'])) continue;
        $results[] = $row;
    }

    if ($tail !== null && $tail > 0 && count($results) > $tail) {
        $results = array_slice($results, -$tail);
    }

    $out = [
        'status'    => 'ok',
        'done'      => $done,
        'results'   => $results,
        'summary'   => $summary,
        'cancelled' => $cancelled,
    ];
    if ($error !== null) $out['error'] = $error;
    return $out;
}

// ---------------- Dispatcher ----------------

// GET status poll — back-compat with the bulkResultsModal polling URL.
if ($is_status_poll) {
    $job_id = (string)($_GET['job_id'] ?? '');
    $job_id = preg_replace('/[^A-Za-z0-9_\-]/', '', $job_id);
    if ($job_id === '') {
        http_response_code(400);
        echo json_encode(['status' => 'error', 'message' => 'missing_job_id']);
        exit;
    }
    echo json_encode(whp_bulk_read_status($job_id));
    exit;
}

// POST dispatcher.
$bulk_action = (string)($_POST['bulk_action'] ?? '');

// ---- get_status (POST alias of GET status, with optional tail) ---------
if ($bulk_action === 'get_status') {
    $job_id = (string)($_POST['job_id'] ?? '');
    $job_id = preg_replace('/[^A-Za-z0-9_\-]/', '', $job_id);
    if ($job_id === '') {
        http_response_code(400);
        echo json_encode(['status' => 'error', 'message' => 'missing_job_id']);
        exit;
    }
    $tail = isset($_POST['tail']) ? max(1, (int)$_POST['tail']) : null;
    echo json_encode(whp_bulk_read_status($job_id, $tail));
    exit;
}

// ---- cancel_job (POST) -------------------------------------------------
if ($bulk_action === 'cancel_job') {
    $job_id = (string)($_POST['job_id'] ?? '');
    $job_id = preg_replace('/[^A-Za-z0-9_\-]/', '', $job_id);
    if ($job_id === '') {
        http_response_code(400);
        echo json_encode(['status' => 'error', 'message' => 'missing_job_id']);
        exit;
    }

    // Ownership: non-root can only cancel jobs they spawned. The spec JSON
    // carries 'actor' so we cross-check before stopping the unit. (A bad
    // actor stopping someone else's run is a denial-of-service but not a
    // data-leak — still, scope the action.)
    $specPath = whp_bulk_spec_path($job_id);
    if (is_file($specPath)) {
        $spec = json_decode((string)@file_get_contents($specPath), true);
        if (is_array($spec) && !$is_root && ($spec['actor'] ?? '') !== AUTH_USER) {
            http_response_code(403);
            echo json_encode(['status' => 'error', 'message' => 'forbidden']);
            exit;
        }
    } elseif (!$is_root) {
        // No spec on disk means we can't verify ownership; refuse for non-root.
        http_response_code(404);
        echo json_encode(['status' => 'error', 'message' => 'job_not_found']);
        exit;
    }

    // systemctl stop sends SIGTERM. The worker's pcntl handler writes a
    // cancellation row and the unit's TimeoutStopSec=30 gives it time to
    // do so before SIGKILL.
    $unitName = "whp-bulk-sites@{$job_id}.service";
    $stopCmd  = "/usr/bin/systemctl stop --no-block " . escapeshellarg($unitName);
    $output = []; $returnCode = 0;
    exec($stopCmd . ' 2>&1', $output, $returnCode);

    if ($returnCode !== 0) {
        // The unit may not exist (legacy-fallback spawn) — that's fine,
        // we have nothing to stop. Surface the error but don't 500.
        error_log("sites-bulk cancel: systemctl stop rc={$returnCode}: " . implode("\n", $output));
        echo json_encode([
            'status' => 'ok',
            'cancelled_dispatched' => false,
            'note' => 'unit_stop_failed_or_already_inactive',
        ]);
        exit;
    }

    echo json_encode([
        'status' => 'ok',
        'cancelled_dispatched' => true,
        'job_id' => $job_id,
    ]);
    exit;
}

// ---- Job-spawn actions -------------------------------------------------

$known_actions = [
    'recreate_containers',
    'delete_sites',
    'change_container_type',
    'change_resources',
    'toggle_auto_recreate',
    'ssl_renew',
    'add_additional_domain',
];
if (!in_array($bulk_action, $known_actions, true)) {
    http_response_code(400);
    echo json_encode(['status' => 'error', 'message' => 'unknown_bulk_action: ' . $bulk_action]);
    exit;
}

$raw_ids = $_POST['site_ids'] ?? [];
if (is_string($raw_ids)) {
    $decoded = json_decode($raw_ids, true);
    if (is_array($decoded)) $raw_ids = $decoded;
    else $raw_ids = [$raw_ids];
}
if (!is_array($raw_ids)) $raw_ids = [];
$raw_ids = array_values(array_unique(array_map('intval', $raw_ids)));

const WHP_BULK_MAX_SITES = 200;
if (count($raw_ids) > WHP_BULK_MAX_SITES) {
    http_response_code(400);
    echo json_encode(['status' => 'error', 'message' => 'too_many_sites (max ' . WHP_BULK_MAX_SITES . ')']);
    exit;
}
if (empty($raw_ids)) {
    http_response_code(400);
    echo json_encode(['status' => 'error', 'message' => 'no_site_ids']);
    exit;
}

// Pre-spawn ownership filter — produces skipped rows we can return in the
// 202 response so the UI shows them immediately even before the worker has
// started writing to the JSONL stream. The worker re-runs the same check
// against the live DB row.
[$sites, $skipped] = whp_bulk_load_sites($db, $raw_ids, $is_root);

$skipped_results = [];
foreach ($skipped as $sid => $reason) {
    $skipped_results[] = whp_bulk_result((int)$sid, $bulk_action, 'skipped', $reason);
}

// Synchronous actions used to be supported via ?sync=1 — we drop that
// here because the whole point of this refactor is that no action blocks
// the HTTP request. The handful of "fast" actions (toggle_auto_recreate,
// change_resources, add_additional_domain) are still cheap per-site but
// uniform spawn keeps the UI flow consistent — one polling pattern,
// one progress modal, one cancel button.

// Per-action arg passthrough. Each arg is validated minimally here; the
// worker re-validates against the DB before applying. Keep the surface
// narrow — anything not in this map is dropped.
$args = [];
switch ($bulk_action) {
    case 'change_container_type':
        $args['new_container_type_id'] = (int)($_POST['new_container_type_id'] ?? 0);
        if ($args['new_container_type_id'] <= 0) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'missing_new_container_type_id']);
            exit;
        }
        break;
    case 'change_resources':
        $cpu    = isset($_POST['cpu_per_container'])    ? (float)$_POST['cpu_per_container']    : null;
        $memory = isset($_POST['memory_per_container']) ? (int)$_POST['memory_per_container']   : null;
        // FPM override is three-way: absent (no change), literal "clear" (set NULL),
        // or a positive int (set value, clamped [2,50]). The presence of the
        // 'fpm_max_children_override' key in $args (even with value null) is what
        // the worker uses to decide whether to touch the column.
        $rawFpm = $_POST['fpm_max_children_override'] ?? null;
        $fpmKeyPresent = false;
        $fpmValue = null;
        if ($rawFpm === 'clear') {
            $fpmKeyPresent = true;
            $fpmValue = null;
        } elseif ($rawFpm !== null && $rawFpm !== '' && is_numeric($rawFpm) && (int)$rawFpm > 0) {
            $n = (int)$rawFpm;
            if ($n < 2)  $n = 2;
            if ($n > 50) $n = 50;
            $fpmKeyPresent = true;
            $fpmValue = $n;
        }

        if (($cpu === null || $cpu <= 0) && ($memory === null || $memory <= 0) && !$fpmKeyPresent) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'must supply cpu_per_container, memory_per_container, or fpm_max_children_override']);
            exit;
        }
        if ($cpu    !== null && $cpu    > 0) $args['cpu_per_container']    = $cpu;
        if ($memory !== null && $memory > 0) $args['memory_per_container'] = $memory;
        if ($fpmKeyPresent) $args['fpm_max_children_override'] = $fpmValue;
        break;
    case 'toggle_auto_recreate':
        $args['auto_recreate_value'] = (int)($_POST['auto_recreate_value'] ?? 0);
        break;
    case 'add_additional_domain':
        $args['domain_id'] = (int)($_POST['domain_id'] ?? 0);
        if ($args['domain_id'] <= 0) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'missing_domain_id']);
            exit;
        }
        break;
    // recreate_containers, delete_sites, ssl_renew take no extra args.
}

if (empty($sites)) {
    // All site_ids filtered out. Return immediately with the skipped rows
    // — no point spawning a worker that has nothing to do.
    $skip = count($skipped_results);
    echo json_encode([
        'status'   => 'ok',
        'job_id'   => null,
        'accepted' => false,
        'results'  => $skipped_results,
        'summary'  => ['ok' => 0, 'failed' => 0, 'skipped' => $skip, 'total' => $skip],
    ]);
    exit;
}

// ---- Write the spec, init the JSONL, spawn the worker ------------------

$job_id   = whp_bulk_new_job_id($bulk_action);
$specPath = whp_bulk_spec_path($job_id);
$jsonlPath = whp_bulk_job_path($job_id);

$spec = [
    'job_id'     => $job_id,
    'action'     => $bulk_action,
    'actor'      => $actor,
    'is_root'    => $is_root,
    'site_ids'   => array_map(fn($s) => (int)$s['id'], $sites),
    'args'       => $args,
    'created_at' => time(),
];

if (file_put_contents($specPath, json_encode($spec, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX) === false) {
    http_response_code(500);
    echo json_encode(['status' => 'error', 'message' => 'failed_to_write_spec']);
    exit;
}
@chmod($specPath, 0640);

// Initialize JSONL stream and front-load the skipped rows so the first
// poll already shows the access-denied / not-found entries.
@file_put_contents($jsonlPath, '');
@chmod($jsonlPath, 0640);
foreach ($skipped_results as $sr) {
    @file_put_contents($jsonlPath, json_encode($sr) . "\n", FILE_APPEND | LOCK_EX);
}

$launch = whp_bulk_launch_worker($job_id);
if (!$launch['success']) {
    // Clean up the partial state so the next attempt with a fresh job_id
    // starts from scratch. (We don't delete the spec/jsonl in normal
    // operation — they're the audit trail.)
    @unlink($specPath);
    @unlink($jsonlPath);
    http_response_code(500);
    echo json_encode([
        'status'  => 'error',
        'message' => 'worker_launch_failed: ' . ($launch['error'] ?? 'unknown'),
    ]);
    exit;
}

// HTTP 202 Accepted — the worker is started and the panel should poll for
// progress. We include the pre-computed skipped rows so the UI can render
// them in the results modal before the first poll completes.
http_response_code(202);
echo json_encode([
    'status'   => 'ok',
    'job_id'   => $job_id,
    'accepted' => true,
    'mode'     => $launch['mode'] ?? 'systemd',
    'queued'   => count($sites),
    'results'  => $skipped_results,
    'summary'  => [
        'ok'      => 0,
        'failed'  => 0,
        'skipped' => count($skipped_results),
        'total'   => count($skipped_results) + count($sites),
    ],
]);
