#!/usr/bin/env php
<?php
/**
 * WHP bulk-sites worker
 *
 * Usage: php run-bulk-sites.php <job_id>
 *
 * Reads the job spec from /docker/whp/sql/bulk-jobs/<job_id>.json, executes
 * the per-site handler, and appends one JSONL row per result to
 * /docker/whp/sql/bulk-jobs/<job_id>.jsonl. The HTTP-side endpoint
 * (api/sites-bulk.php) returns 202 immediately after writing the spec and
 * starting this worker via `systemctl start whp-bulk-sites@<job_id>.service`.
 *
 * Spec JSON shape (written by sites-bulk.php):
 *   {
 *     "job_id":  "recreate_containers-YYYYMMDD-HHMMSS-XXXXXXXX",
 *     "action":  "recreate_containers" | "delete_sites" | ...
 *     "actor":   "<AUTH_USER>",
 *     "is_root": true|false,
 *     "site_ids": [123, 456, ...],
 *     "args":    { ... action-specific args ... },
 *     "created_at": 1717000000
 *   }
 */

require_once '/docker/whp/web/includes/safety-net.php';

if ($argc < 2) {
    fwrite(STDERR, "Usage: php run-bulk-sites.php <job_id>\n");
    exit(2);
}

$jobId = $argv[1];
if (!preg_match('/^[A-Za-z0-9_\-]{8,128}$/', $jobId)) {
    fwrite(STDERR, "Invalid job_id (regex)\n");
    exit(2);
}

$bulkJobsDir = '/docker/whp/sql/bulk-jobs';
$specPath    = $bulkJobsDir . '/' . $jobId . '.json';
$jsonlPath   = $bulkJobsDir . '/' . $jobId . '.jsonl';

if (!is_file($specPath)) {
    fwrite(STDERR, "Spec file not found: $specPath\n");
    exit(2);
}

$rawSpec = @file_get_contents($specPath);
if ($rawSpec === false) {
    fwrite(STDERR, "Failed to read spec: $specPath\n");
    exit(2);
}
$spec = json_decode($rawSpec, true);
if (!is_array($spec)) {
    fwrite(STDERR, "Spec is not valid JSON\n");
    exit(2);
}

$bulkAction = (string)($spec['action'] ?? '');
$siteIds    = $spec['site_ids'] ?? [];
$args       = is_array($spec['args'] ?? null) ? $spec['args'] : [];
$isRoot     = !empty($spec['is_root']);
$actor      = (string)($spec['actor'] ?? 'unknown');
if (!is_array($siteIds)) $siteIds = [];

function bulk_append(string $path, array $row): void {
    $row['at'] = $row['at'] ?? time();
    @file_put_contents($path, json_encode($row) . "\n", FILE_APPEND | LOCK_EX);
}

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

bulk_append($jsonlPath, [
    '__queued__' => true,
    'job_id'     => $jobId,
    'action'     => $bulkAction,
    'total'      => count($siteIds),
]);

// Cancellation handling (SIGTERM from systemctl stop). The unit's
// TimeoutStopSec=30 gives us a window to write a cancellation line before
// SIGKILL escalation.
$cancelRequested = false;
$cancelHandler = function (int $signo) use (&$cancelRequested, $jsonlPath) {
    $cancelRequested = true;
    @file_put_contents(
        $jsonlPath,
        json_encode([
            '__cancelling__' => true,
            'reason'         => 'SIGTERM',
            'at'             => time(),
        ]) . "\n",
        FILE_APPEND | LOCK_EX
    );
};
if (function_exists('pcntl_signal') && function_exists('pcntl_async_signals')) {
    pcntl_async_signals(true);
    pcntl_signal(SIGTERM, $cancelHandler);
    pcntl_signal(SIGINT,  $cancelHandler);
}

require_once '/docker/whp/web/configs/config.php';
require_once '/docker/whp/web/libs/mysqlmgmt.php';

try {
    $MySQLMgmt = new mysqlmgmt();
    $db = $MySQLMgmt->getMySQLConnection();
    if (!$db) {
        throw new Exception('Failed to establish database connection');
    }
    $db->exec("USE whp");
} catch (Throwable $e) {
    bulk_append($jsonlPath, [
        '__done__'    => true,
        'summary'     => ['ok' => 0, 'failed' => count($siteIds), 'skipped' => 0, 'total' => count($siteIds)],
        'error'       => 'database_init_failed: ' . $e->getMessage(),
        'finished_at' => time(),
    ]);
    fwrite(STDERR, "DB init failed: " . $e->getMessage() . "\n");
    exit(1);
}

function load_sites_by_ids(PDO $db, array $site_ids, bool $is_root, string $actor): 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.*,
            COALESCE(sd.fqdn, d.domain_name) AS primary_domain,
            ct.name                          AS container_type_name,
            ct.base_image,
            ct.listen_port,
            ct.startup_env,
            ct.mount_options
        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 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'] !== $actor) {
            $skipped[$sid] = 'access_denied';
            continue;
        }
        $ordered[] = $row;
    }
    return [$ordered, $skipped];
}

[$sites, $skipped] = load_sites_by_ids($db, $siteIds, $isRoot, $actor);

foreach ($skipped as $sid => $reason) {
    bulk_append($jsonlPath, bulk_result((int)$sid, $bulkAction, 'skipped', $reason));
}

function recreate_one_site(PDO $db, $DockerAPI, $MySQLMgmt, array $site): array {
    $site_id = (int)$site['id'];
    $errs = [];

    $stmt = $db->prepare("SELECT * FROM whp.site_containers WHERE site_id = ? ORDER BY container_number ASC");
    $stmt->execute([$site_id]);
    $existing = $stmt->fetchAll(PDO::FETCH_ASSOC);

    $uid = trim(shell_exec("id -u " . escapeshellarg($site['username'])));
    if (!is_numeric($uid)) {
        return bulk_result($site_id, 'recreate_containers', 'failed', 'invalid_user');
    }

    $safe_domain = !empty($site['is_wildcard'])
        ? str_replace('*.', 'wildcard.', $site['primary_domain'])
        : $site['primary_domain'];

    $created = 0;
    for ($n = 1; $n <= (int)$site['container_count']; $n++) {
        $existing_match = null;
        foreach ($existing as $c) {
            if ((int)$c['container_number'] === $n) { $existing_match = $c; break; }
        }
        if ($existing_match) {
            $DockerAPI->stop_container($existing_match['container_id']);
            $DockerAPI->remove_container($existing_match['container_id']);
        }

        $container_name = $safe_domain . '-' . str_pad((string)$n, 2, '0', STR_PAD_LEFT);
        $env_vars = [
            'WHP_USER='             . $site['username'],
            'WHP_UID='              . $uid,
            'WHP_DOMAIN='           . $site['primary_domain'],
            'WHP_CONTAINER_NUMBER=' . $n,
        ];
        if (!empty($site['user_variables'])) {
            $uv = json_decode($site['user_variables'], true) ?: [];
            foreach ($uv as $k => $v) $env_vars[] = "{$k}={$v}";
        }
        if (!empty($site['startup_env'])) {
            $se = json_decode($site['startup_env'], true) ?: [];
            foreach ($se as $k => $v) {
                $v = $MySQLMgmt->replaceDynamicVariables($v, $site['username'], $site['primary_domain'], $uid, $n, $site['listen_port'] ?? 80);
                $env_vars[] = "{$k}={$v}";
            }
        }

        $volumes = [];
        if (!empty($site['mount_options'])) {
            $mo = json_decode($site['mount_options'], true) ?: [];
            if (isset($mo['volumes'])) {
                foreach ($mo['volumes'] as $vol) {
                    $src = $MySQLMgmt->replaceDynamicVariables($vol['source'], $site['username'], $safe_domain, $uid, $n, $site['listen_port'] ?? 80);
                    $tgt = $MySQLMgmt->replaceDynamicVariables($vol['target'], $site['username'], $safe_domain, $uid, $n, $site['listen_port'] ?? 80);
                    $volumes[] = "{$src}:{$tgt}";
                }
            }
        }

        $cfg = [
            'image'   => $site['base_image'],
            'name'    => $container_name,
            'labels'  => [
                'whp.user'             => $site['username'],
                'whp.domain'           => $site['primary_domain'],
                'whp.site_id'          => strval($site_id),
                'whp.container_number' => strval($n),
            ],
            'env'     => $env_vars,
            'volumes' => $volumes,
            'cpu'     => $site['cpu_per_container'],
            'memory'  => $site['memory_per_container'],
            'network' => 'client-net',
        ];

        $res = $DockerAPI->create_container($cfg);
        if ($res['success']) {
            if ($existing_match) {
                $db->prepare("UPDATE whp.site_containers SET container_id = ?, status = 'running' WHERE site_id = ? AND container_number = ?")
                   ->execute([$res['container_id'], $site_id, $n]);
            } else {
                $db->prepare("INSERT INTO whp.site_containers (site_id, container_id, container_number, status) VALUES (?, ?, ?, 'running')")
                   ->execute([$site_id, $res['container_id'], $n]);
            }
            @whp_valkey_attach_if_enabled($db, $site['username'], $container_name);
            $created++;
        } else {
            $errs[] = "container {$n}: " . ($res['error'] ?? 'unknown');
        }
    }

    if ($created > 0) {
        require_once('/docker/whp/web/libs/haproxy_manager.php');
        $hp = (new haproxy_manager())->configureSite($site_id, $site);
        if (!$hp['success']) {
            $errs[] = "haproxy: " . ($hp['error'] ?? 'unknown');
        }
    }

    if ($created === 0) {
        return bulk_result($site_id, 'recreate_containers', 'failed', 'no_containers_created: ' . implode('; ', $errs));
    }
    if (!empty($errs)) {
        return bulk_result($site_id, 'recreate_containers', 'failed', "recreated {$created} with warnings: " . implode('; ', $errs));
    }
    return bulk_result($site_id, 'recreate_containers', 'ok', "recreated {$created} container(s)");
}

$processed = 0;
$ok = 0; $fail = 0; $skip = count($skipped);

function check_cancel(string $jsonlPath, string $bulkAction, array $remaining_sites, bool $cancelRequested, int $ok, int $fail, int $skip): bool {
    if (!$cancelRequested) return false;
    foreach ($remaining_sites as $rs) {
        bulk_append($jsonlPath, bulk_result((int)$rs['id'], $bulkAction, 'skipped', 'cancelled'));
    }
    bulk_append($jsonlPath, [
        '__done__'    => true,
        'cancelled'   => true,
        'reason'      => 'SIGTERM',
        'summary'     => [
            'ok'      => $ok,
            'failed'  => $fail,
            'skipped' => $skip + count($remaining_sites),
            'total'   => $ok + $fail + $skip + count($remaining_sites),
        ],
        'finished_at' => time(),
    ]);
    return true;
}

try {
    if ($bulkAction === 'recreate_containers') {
        require_once('/docker/whp/web/libs/docker-api.php');
        require_once('/docker/whp/web/libs/valkey-hook.php');
        $DockerAPI = new docker_api();

        $images = [];
        foreach ($sites as $s) $images[$s['base_image']] = true;
        foreach (array_keys($images) as $img) @$DockerAPI->pull_image($img);

        for ($i = 0; $i < count($sites); $i++) {
            if (check_cancel($jsonlPath, $bulkAction, array_slice($sites, $i), $cancelRequested, $ok, $fail, $skip)) {
                exit(0);
            }
            $s = $sites[$i];
            $r = recreate_one_site($db, $DockerAPI, $MySQLMgmt, $s);
            bulk_append($jsonlPath, $r);
            if ($r['status'] === 'ok') $ok++; elseif ($r['status'] === 'failed') $fail++; else $skip++;
            $processed++;
        }

    } elseif ($bulkAction === 'change_container_type') {
        require_once('/docker/whp/web/libs/docker-api.php');
        require_once('/docker/whp/web/libs/valkey-hook.php');
        $DockerAPI = new docker_api();
        $new_id = (int)($args['new_container_type_id'] ?? 0);
        if ($new_id <= 0) {
            foreach ($sites as $s) {
                bulk_append($jsonlPath, bulk_result((int)$s['id'], 'change_container_type', 'failed', 'invalid_container_type_id'));
                $fail++;
            }
        } else {
            $stmt = $db->prepare("SELECT * FROM whp.container_types WHERE id = ? AND active = 1");
            $stmt->execute([$new_id]);
            $new_type = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$new_type) {
                foreach ($sites as $s) {
                    bulk_append($jsonlPath, bulk_result((int)$s['id'], 'change_container_type', 'failed', 'container_type_not_found'));
                    $fail++;
                }
            } else {
                $pull = $DockerAPI->pull_image($new_type['base_image']);
                if (!$pull['success']) {
                    foreach ($sites as $s) {
                        bulk_append($jsonlPath, bulk_result((int)$s['id'], 'change_container_type', 'failed', 'image_pull_failed: ' . ($pull['error'] ?? 'unknown')));
                        $fail++;
                    }
                } else {
                    for ($i = 0; $i < count($sites); $i++) {
                        if (check_cancel($jsonlPath, $bulkAction, array_slice($sites, $i), $cancelRequested, $ok, $fail, $skip)) {
                            exit(0);
                        }
                        $site = $sites[$i];
                        $site_id = (int)$site['id'];
                        try {
                            $db->prepare("UPDATE whp.sites SET container_type_id = ? WHERE id = ?")
                               ->execute([$new_id, $site_id]);

                            $stmt = $db->prepare("
                                SELECT s.*,
                                       COALESCE(sd.fqdn, d.domain_name) as primary_domain,
                                       ct.base_image, ct.listen_port, ct.startup_env, ct.mount_options
                                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([$site_id]);
                            $reloaded = $stmt->fetch(PDO::FETCH_ASSOC);

                            $r = recreate_one_site($db, $DockerAPI, $MySQLMgmt, $reloaded);
                            $r['action'] = 'change_container_type';
                            bulk_append($jsonlPath, $r);
                            if ($r['status'] === 'ok') $ok++; elseif ($r['status'] === 'failed') $fail++; else $skip++;
                        } catch (Throwable $e) {
                            bulk_append($jsonlPath, bulk_result($site_id, 'change_container_type', 'failed', 'exception: ' . $e->getMessage()));
                            $fail++;
                        }
                        $processed++;
                    }
                }
            }
        }

    } elseif ($bulkAction === 'ssl_renew') {
        require_once('/docker/whp/web/libs/ssl_manager.php');
        $mgr = new ssl_manager();
        for ($i = 0; $i < count($sites); $i++) {
            if (check_cancel($jsonlPath, $bulkAction, array_slice($sites, $i), $cancelRequested, $ok, $fail, $skip)) {
                exit(0);
            }
            $site = $sites[$i];
            $site_id = (int)$site['id'];
            try {
                $r = $mgr->renew_ssl($site_id);
                if (!empty($r['success'])) {
                    $row = bulk_result($site_id, 'ssl_renew', 'ok', 'renewed');
                    $ok++;
                } else {
                    $row = bulk_result($site_id, 'ssl_renew', 'failed', $r['error'] ?? 'unknown');
                    $fail++;
                }
            } catch (Throwable $e) {
                $row = bulk_result($site_id, 'ssl_renew', 'failed', 'exception: ' . $e->getMessage());
                $fail++;
            }
            bulk_append($jsonlPath, $row);
            $processed++;
        }

    } elseif ($bulkAction === 'delete_sites') {
        require_once('/docker/whp/web/libs/docker-api.php');
        require_once('/docker/whp/web/libs/haproxy_manager.php');
        require_once('/docker/whp/web/libs/pdns-integration.php');
        $DockerAPI = new docker_api();
        $haproxy_manager = new haproxy_manager();
        $PowerDNS = new PowerDNSIntegration($db);

        for ($i = 0; $i < count($sites); $i++) {
            if (check_cancel($jsonlPath, $bulkAction, array_slice($sites, $i), $cancelRequested, $ok, $fail, $skip)) {
                exit(0);
            }
            $site = $sites[$i];
            $site_id = (int)$site['id'];
            $errs = [];
            try {
                $stmt = $db->prepare("SELECT container_id FROM whp.site_containers WHERE site_id = ?");
                $stmt->execute([$site_id]);
                foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $c) {
                    $cid = $c['container_id'];
                    $sr = $DockerAPI->stop_container($cid);
                    if (!$sr['success']) $errs[] = "stop {$cid}: " . ($sr['error'] ?? 'unknown');
                    $rr = $DockerAPI->remove_container($cid);
                    if (!$rr['success']) $errs[] = "remove {$cid}: " . ($rr['error'] ?? 'unknown');
                }

                $hpr = $haproxy_manager->removeSite($site_id);
                if (!$hpr['success']) {
                    $errs[] = "haproxy: " . ($hpr['error'] ?? 'unknown');
                }

                if (!empty($site['is_wildcard'])) {
                    $stmt = $db->prepare("SELECT sd.fqdn FROM whp.site_domains sd WHERE sd.site_id = ? AND sd.is_primary = 1");
                    $stmt->execute([$site_id]);
                    $row = $stmt->fetch(PDO::FETCH_ASSOC);
                    if ($row && strpos($row['fqdn'], '*.') === 0) {
                        $base = substr($row['fqdn'], 2);
                        @$PowerDNS->removeWildcardRecord($base);
                        @$PowerDNS->removeDnsChallengeRecord($base);
                    }
                    $db->prepare("DELETE FROM whp.dns_challenges WHERE site_id = ?")->execute([$site_id]);
                }

                $stmt = $db->prepare("DELETE FROM whp.sites WHERE id = ?");
                $stmt->execute([$site_id]);

                $row = bulk_result(
                    $site_id,
                    'delete_site',
                    'ok',
                    empty($errs) ? 'deleted: ' . $site['site_name']
                                 : 'deleted with warnings: ' . implode('; ', $errs)
                );
                $ok++;
            } catch (Throwable $e) {
                $row = bulk_result($site_id, 'delete_site', 'failed', 'exception: ' . $e->getMessage());
                $fail++;
            }
            bulk_append($jsonlPath, $row);
            $processed++;
        }

    } elseif ($bulkAction === 'toggle_auto_recreate') {
        $value = (int)($args['auto_recreate_value'] ?? 0);
        foreach ($sites as $site) {
            if (check_cancel($jsonlPath, $bulkAction, array_slice($sites, $processed), $cancelRequested, $ok, $fail, $skip)) {
                exit(0);
            }
            $stmt = $db->prepare("UPDATE whp.sites SET auto_recreate_on_update = ? WHERE id = ?");
            $stmt->execute([$value ? 1 : 0, (int)$site['id']]);
            bulk_append($jsonlPath, bulk_result(
                (int)$site['id'],
                'toggle_auto_recreate',
                'ok',
                'auto_recreate_on_update set to ' . ($value ? '1' : '0')
            ));
            $ok++;
            $processed++;
        }

    } elseif ($bulkAction === 'change_resources') {
        $cpu    = isset($args['cpu_per_container'])    ? (float)$args['cpu_per_container']    : null;
        $memory = isset($args['memory_per_container']) ? (int)$args['memory_per_container']   : null;
        foreach ($sites as $site) {
            if (check_cancel($jsonlPath, $bulkAction, array_slice($sites, $processed), $cancelRequested, $ok, $fail, $skip)) {
                exit(0);
            }
            $sets = [];
            $params = [];
            if ($cpu !== null && $cpu > 0) {
                $sets[] = "cpu_per_container = ?";
                $params[] = $cpu;
            }
            if ($memory !== null && $memory > 0) {
                $sets[] = "memory_per_container = ?";
                $params[] = $memory;
            }
            if (empty($sets)) {
                bulk_append($jsonlPath, bulk_result((int)$site['id'], 'change_resources', 'skipped', 'no_changes'));
                $skip++;
            } else {
                $params[] = (int)$site['id'];
                $stmt = $db->prepare("UPDATE whp.sites SET " . implode(', ', $sets) . " WHERE id = ?");
                $stmt->execute($params);
                bulk_append($jsonlPath, bulk_result(
                    (int)$site['id'],
                    'change_resources',
                    'ok',
                    'limits updated; containers must be recreated for change to take effect'
                ));
                $ok++;
            }
            $processed++;
        }

    } elseif ($bulkAction === 'add_additional_domain') {
        $domain_id = (int)($args['domain_id'] ?? 0);
        if ($domain_id <= 0) {
            foreach ($sites as $s) {
                bulk_append($jsonlPath, bulk_result((int)$s['id'], 'add_additional_domain', 'failed', 'invalid_domain_id'));
                $fail++;
            }
        } else {
            $stmt = $db->prepare("SELECT id, domain_name, username FROM whp.domains WHERE id = ?");
            $stmt->execute([$domain_id]);
            $domain = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$domain) {
                foreach ($sites as $s) {
                    bulk_append($jsonlPath, bulk_result((int)$s['id'], 'add_additional_domain', 'failed', 'domain_not_found'));
                    $fail++;
                }
            } else {
                foreach ($sites as $site) {
                    if (check_cancel($jsonlPath, $bulkAction, array_slice($sites, $processed), $cancelRequested, $ok, $fail, $skip)) {
                        exit(0);
                    }
                    $site_id = (int)$site['id'];

                    if (!$isRoot && $domain['username'] !== $actor) {
                        bulk_append($jsonlPath, bulk_result($site_id, 'add_additional_domain', 'failed', 'domain_access_denied'));
                        $fail++;
                        $processed++;
                        continue;
                    }

                    $stmt = $db->prepare("SELECT id FROM whp.site_domains WHERE site_id = ? AND domain_id = ?");
                    $stmt->execute([$site_id, $domain_id]);
                    if ($stmt->fetch()) {
                        bulk_append($jsonlPath, bulk_result($site_id, 'add_additional_domain', 'skipped', 'already_linked: ' . $domain['domain_name']));
                        $skip++;
                        $processed++;
                        continue;
                    }

                    try {
                        $stmt = $db->prepare("INSERT INTO whp.site_domains (site_id, domain_id, is_primary) VALUES (?, ?, 0)");
                        $stmt->execute([$site_id, $domain_id]);
                        bulk_append($jsonlPath, bulk_result(
                            $site_id,
                            'add_additional_domain',
                            'ok',
                            'linked ' . $domain['domain_name']
                        ));
                        $ok++;
                    } catch (Throwable $e) {
                        bulk_append($jsonlPath, bulk_result($site_id, 'add_additional_domain', 'failed', 'db_error: ' . $e->getMessage()));
                        $fail++;
                    }
                    $processed++;
                }
            }
        }

    } else {
        bulk_append($jsonlPath, [
            '__done__' => true,
            'summary'  => ['ok' => 0, 'failed' => 0, 'skipped' => count($siteIds), 'total' => count($siteIds)],
            'error'    => 'unknown_bulk_action: ' . $bulkAction,
            'finished_at' => time(),
        ]);
        fwrite(STDERR, "Unknown bulk_action: $bulkAction\n");
        exit(2);
    }

    bulk_append($jsonlPath, [
        '__done__'    => true,
        'summary'     => [
            'ok'      => $ok,
            'failed'  => $fail,
            'skipped' => $skip,
            'total'   => $ok + $fail + $skip,
        ],
        'finished_at' => time(),
    ]);

} catch (Throwable $e) {
    bulk_append($jsonlPath, [
        '__done__'    => true,
        'summary'     => [
            'ok'      => $ok,
            'failed'  => $fail + (count($sites) - $processed),
            'skipped' => $skip,
            'total'   => $ok + $fail + $skip + (count($sites) - $processed),
        ],
        'error'       => 'worker_exception: ' . $e->getMessage(),
        'finished_at' => time(),
    ]);
    fwrite(STDERR, "Worker exception: " . $e->getMessage() . "\n");
    exit(1);
}

exit(0);
