#!/usr/bin/env php
<?php

declare(strict_types=1);

/**
 * Copyright (c) 2026 Latch contributors
 *
 * SPDX-License-Identifier: MIT
 */


/**
 * Latch CLI entry point.
 */

use Latch\Core\Cache;
use Latch\Core\Config;
use Latch\Core\CronService;
use Latch\Core\Database;
use Latch\Core\Mail;
use Latch\Core\MailQueueService;
use Latch\Models\MailQueueRepository;
use Latch\Core\Migrator;
use Latch\Core\RateLimiter;
use Latch\Core\ReputationService;
use Latch\Core\SecretCipher;
use Latch\Core\SecurityHeaders;
use Latch\Core\OAuthScopes;
use Latch\Models\NotificationRepository;
use Latch\Models\ApiAuditLogRepository;
use Latch\Models\BoardRepository;
use Latch\Models\OAuthClientRepository;
use Latch\Models\OAuthTokenRepository;
use Latch\Models\EmailChangeRepository;
use Latch\Models\EmailVerificationRepository;
use Latch\Models\PasswordResetRepository;
use Latch\Models\RecoveryCodeRepository;
use Latch\Models\PostRepository;
use Latch\Models\SettingRepository;
use Latch\Core\PostFormatter;
use Latch\Core\RssFeed;
use Latch\Models\RssRepository;
use Latch\Models\SearchRepository;
use Latch\Core\TopicTags;
use Latch\Models\TagRepository;
use Latch\Models\TopicRepository;
use Latch\Models\UserRepository;
use Latch\Models\UserSessionRepository;
use Latch\Models\WebhookRepository;
use Latch\Core\Webhooks\WebhookEvent;
use Latch\Support\SiteLock;
use Latch\Support\SiteMaintenance;
use Latch\Support\Doctor;
use Latch\Support\Logs\LogViewer;
use Latch\Support\Logs\LogViewerException;
use Latch\Support\SiteRestore;
use Latch\Support\SqliteIntegrity;
use Latch\Support\UpdateOrchestrator;
use Latch\Import\Phpbb\BbcodeConverter;
use Latch\Import\Phpbb\PhpbbImporter;
use Latch\Import\Phpbb\PhpbbReader;

define('LATCH_ROOT', dirname(__DIR__));

$autoload = LATCH_ROOT . '/vendor/autoload.php';
if (!is_file($autoload)) {
    fwrite(STDERR, "Run composer install from " . LATCH_ROOT . " first.\n");
    exit(1);
}

require $autoload;

$command = $argv[1] ?? 'help';

match ($command) {
    'help', '--help', '-h' => print_help(),
    'install' => run_install($argv),
    'migrate' => run_migrate(),
    'audit' => run_audit(),
    'fix-perms' => run_fix_perms($argv),
    'backup' => run_backup($argv),
    'cache-clear' => run_cache_clear(),
    'maintenance' => run_maintenance($argv),
    'lock' => run_lock($argv),
    'logs' => run_logs($argv),
    'cron' => run_cron($argv),
    'benchmark' => run_benchmark($argv),
    'test-mail' => run_test_mail($argv),
    'mail' => run_mail($argv),
    'security-bootstrap' => run_security_bootstrap(),
    'configure' => run_configure($argv),
    'totp' => run_totp($argv),
    'search-reindex' => run_search_reindex(),
    'test-rss' => run_test_rss(),
    'test-profiles' => run_test_profiles(),
    'test-spam' => run_test_spam(),
    'test-webhooks' => run_test_webhooks(),
    'post-announcements' => run_post_announcements($argv),
    'purge-users' => run_purge_users($argv),
    'api-client' => run_api_client($argv),
    'test-api' => run_test_api($argv),
    'test-api-messages' => run_test_api_messages($argv),
    'reputation-recompute' => run_reputation_recompute($argv),
    'plugin' => run_plugin($argv),
    'plugin-audit' => run_plugin_audit($argv),
    'db-check' => run_db_check($argv),
    'restore' => run_restore($argv),
    'update' => run_update($argv),
    'doctor' => run_doctor($argv),
    'test' => run_test($argv),
    'import' => run_import($argv),
    default => unknown_command($command),
};

function unknown_command(string $command): void
{
    fwrite(STDERR, "Unknown command: {$command}\n");
    print_help();
    exit(1);
}

function print_help(): void
{
    fwrite(STDOUT, <<<HELP
Latch CLI

  php bin/latch install        Create database, config, and initial admin
  php bin/latch migrate        Apply SQLite schema migrations
  php bin/latch audit          Security + permissions self-check (install/upgrade gate)
  php bin/latch fix-perms      Fix storage/, plugins/, and config perms (sudo on RPM)
  php bin/latch fix-perms --web-user=www-data   Non-apache HTTP stacks (or WEB_USER=…)
  php bin/latch backup         Split tarball: core.tar.gz + plugins.tar.gz (WAL-safe)
  php bin/latch db-check       SQLite integrity + foreign-key check
  php bin/latch restore        List or restore from storage/backups/
  php bin/latch update         Lock → backup → migrate → db-check → unlock
  php bin/latch doctor         Four-layer install preflight (PHP, vendor, DB, perms)
  php bin/latch test           Run PHPUnit suite (or built-in fallback)
  php bin/latch test --smoke   Operator smoke: PHPUnit smoke suite + db-check + audit + optional HTTP
  php bin/latch test --security Security PHPUnit suite + audit + optional HTTP probes
  php bin/latch cache-clear    Purge page cache and Twig compile files
  php bin/latch cron hourly    Light scheduled tasks (rate limits, reputation queue)
  php bin/latch cron daily     DB prunes + reputation (no cache purge)
  php bin/latch cron weekly    ANALYZE, DM/topic_reads cleanup; --audit for audit_log
  php bin/latch maintenance    Run cron daily + optional --clear-cache / --vacuum
  php bin/latch lock on|off|status  Site maintenance lock (blocks web + API, no DB traffic)
  php bin/latch logs list [--json]  List configured log sources and readability status
  php bin/latch logs tail --source=ID [--lines=N] [--follow]  Tail a log (filters match admin viewer)
  php bin/latch benchmark      Curl timing report for key pages
  php bin/latch test-mail      Send a test email (verify msmtp/SMTP)
  php bin/latch mail process   Drain pending notification mail queue
  php bin/latch security-bootstrap  Set encryption_key + re-wrap TOTP secrets (server ops)
  php bin/latch configure        Interactive walkthrough for config/local.php (secrets stay off web UI)
  php bin/latch configure --show Masked status of local.php keys
  php bin/latch totp reset <username> --confirm  Clear 2FA enrolment (re-setup on next admin login)
  php bin/latch search-reindex Rebuild FTS5 search index (topics, posts, tags)
  php bin/latch reputation-recompute [--user=ID]  Recompute member ranks (all or one user)
  php bin/latch test-rss       Run RSS unit tests + validate live feed XML
  php bin/latch test-profiles  Run public profile unit tests
  php bin/latch test-spam      Run spam control unit tests
  php bin/latch test-webhooks  Run webhook repository unit tests
  php bin/latch post-announcements  Post changelog replies to a topic (operator)
  php bin/latch purge-users    Delete member accounts with no posts/topics (spam cleanup)
  php bin/latch api-client     Create, list, or revoke OAuth API clients (admin)
  php bin/latch test-api       Live smoke tests for REST API + OAuth (see tests/api/)
  php bin/latch test-api-messages  Messages API + user OAuth (PKCE); see tests/api/
  php bin/latch plugin list [--all]  List plugins ( --all includes ignored)
  php bin/latch plugin audit <path|slug>  Security scan (alias for plugin-audit)
  php bin/latch plugin install <path>  Copy directory or .zip into plugins/{slug}/ (audit gate)
  php bin/latch plugin update <slug> [--from <dir|zip>]  Replace installed plugin from catalog or local source
  php bin/latch plugin remove <slug> --confirm  Disable and delete installed plugin
  php bin/latch plugin enable <slug> [--force]  Enable after audit pass
  php bin/latch plugin disable <slug>  Disable a plugin
  php bin/latch plugin ignore <slug>  Mark plugin ignored in plugin.json (CLI only)
  php bin/latch plugin unignore <slug>  Restore an ignored plugin
  php bin/latch plugin-audit <path|slug>  Static security scan; JSON with --json
  php bin/latch import phpbb   Import phpBB 3.3.x bundle (or export from MySQL)
  php bin/latch help           Show this message

Test API options:
  --url=URL                    Override base_url from config
  --config=PATH                Config file (default: tests/api/config.local.php)

API client options (api-client create):
  --name=NAME                  Application name (required)
  --redirect=URL               Redirect URI (repeatable; required for public clients)
  --public                     Public client (PKCE, no client_secret)
  --rate-limit=N               Requests per minute (default: 60)
  --user=NAME                  Admin username creating the client (default: first admin)

API client options (api-client revoke):
  --client-id=ID               Client id to revoke (required)

phpBB import options (import phpbb):
  --bundle=PATH                JSON bundle to import (required for import)
  --dry-run                    Preview counts and warnings (no writes)
  --confirm                    Write import to database
  --export                     Export phpBB MySQL database to JSON bundle
  --from-mysql=DSN             mysqli://user:pass@host/dbname (export mode)
  --out=PATH                   Output path for --export
  --prefix=phpbb_              Table prefix for export (default: phpbb_)
  --json                       Machine-readable report

Post announcements options:
  --topic=ID                   Topic ID (required)
  --user=NAME                  Username to post as (required)
  --file=PATH                  JSON file (default: data/changelog-announcements.json)
  --dry-run                    Print what would be posted without writing

Install options:
  --url=URL                    Site URL (default: http://localhost)
  --name=NAME                  Site name (default: Latch)
  --admin-user=USER            Admin username (default: admin)
  --admin-email=EMAIL          Admin email (default: admin@localhost)
  --admin-pass=PASS            Admin password (prompted if omitted)
  --no-seed-board              Skip creating the default General board
  --no-configure               Skip optional post-install configure walkthrough prompt

Configure options:
  --show                       Masked status of config/local.php (no write)
  --section=LIST               site,security,turnstile,staff,oidc,mail,plugins (default: all)

Maintenance options:
  --clear-cache                Purge guest page cache and Twig compile files
  --vacuum                     Run SQLite VACUUM after cleanup

Cron options:
  --audit                      With cron weekly: also prune audit_log rows

Benchmark options:
  --url=URL                    Base URL (default: from config)
  --iterations=N               Requests per path (default: 10)

Test mail options:
  --to=EMAIL                   Recipient (required)

HELP);
}

function require_pdo(): void
{
    if (class_exists(PDO::class)) {
        return;
    }

    fwrite(STDERR, "PHP PDO is not installed (required for SQLite).\n\n");
    fwrite(STDERR, "On Fedora/RHEL:\n");
    fwrite(STDERR, "  sudo dnf install -y php-pdo php-mbstring\n\n");
    fwrite(STDERR, "Then re-run this command.\n");
    exit(1);
}

function run_migrate(): void
{
    require_pdo();

    $config = new Config(LATCH_ROOT . '/config');
    $dbPath = (string) $config->get('database.path');

    $db = latch_cli_database($config);
    $migrator = new Migrator($db, LATCH_ROOT . '/database/migrations');
    $applied = $migrator->migrate();

    fwrite(STDOUT, "Migrations applied: {$applied}\n");

    maybe_auto_search_reindex($db);
}

/**
 * @return list<string>
 */
function collect_audit_issues(Config $config): array
{
    $issues = [];

    if (!$config->isInstalled()) {
        $issues[] = 'Database not installed';
    }

    $issues = array_merge($issues, Doctor::permissionIssuesForAudit($config));

    $publicPath = LATCH_ROOT . '/public';
    $sensitive = ['../storage', '../config/local.php', '../vendor'];
    foreach ($sensitive as $rel) {
        $target = realpath($publicPath . '/' . $rel);
        if ($target !== false && str_starts_with($target, realpath($publicPath) ?: $publicPath)) {
            $issues[] = "Sensitive path reachable from public/: {$rel}";
        }
    }

    if (!class_exists(SecurityHeaders::class)) {
        $issues[] = 'SecurityHeaders class missing';
    }

    $fail2banInstalled = is_file('/etc/fail2ban/filter.d/latch-login.conf');
    $fail2banTemplate = dirname(LATCH_ROOT) . '/deploy/server/fail2ban-latch-login.conf';
    if (!$fail2banInstalled && !is_file($fail2banTemplate)) {
        $issues[] = 'fail2ban filter missing (install latch RPM or deploy/server/fail2ban-latch-login.conf)';
    }

    if (ini_get('display_errors') === '1') {
        $issues[] = 'display_errors is enabled (may leak debug info)';
    }

    if (trim((string) $config->get('security.encryption_key', '')) === '') {
        $issues[] = 'security.encryption_key not set in local.php (TOTP uses derived fallback)';
    }

    if ($config->isInstalled()) {
        $db = latch_cli_database($config);
        $settings = new SettingRepository($db);
        $mail = new Mail($config, $settings);
        if ($settings->getBool('require_email_verification') && !$mail->isConfigured()) {
            $issues[] = 'Email verification enabled but outbound mail is not configured';
        }
    }

    return $issues;
}

function run_audit(): void
{
    require_pdo();

    $config = new Config(LATCH_ROOT . '/config');
    $issues = collect_audit_issues($config);

    if ($issues === []) {
        fwrite(STDOUT, "audit: OK — no issues found\n");
        exit(0);
    }

    Doctor::writeAuditFailure($issues);
    exit(1);
}

function run_fix_perms(array $argv): void
{
    $opts = parse_cli_options($argv);
    $webUser = isset($opts['web-user']) ? trim((string) $opts['web-user']) : null;
    if ($webUser === '') {
        fwrite(STDERR, "fix-perms: --web-user must not be empty\n");
        exit(1);
    }

    if ($webUser !== null) {
        putenv('LATCH_WEB_USER=' . $webUser);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $storagePath = (string) $config->get('paths.storage');
    $pluginsPath = (string) $config->get('paths.plugins');
    $dbPath = (string) $config->get('database.path');
    $localConfigPath = LATCH_ROOT . '/config/local.php';
    if (!is_file($localConfigPath)) {
        $localConfigPath = null;
    }

    $result = \Latch\Core\Plugins\PluginStoragePermissions::fixRuntimePermissions(
        $storagePath,
        $pluginsPath,
        $localConfigPath,
        $dbPath,
        $webUser,
    );

    if ($result['ok']) {
        fwrite(STDOUT, 'fix-perms: ' . $result['message'] . "\n");
        exit(0);
    }

    fwrite(STDERR, 'fix-perms: ' . $result['message'] . "\n");
    exit(1);
}

function run_db_check(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $dbPath = (string) ($opts['db'] ?? $config->get('database.path'));

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

    $quickOnly = isset($opts['quick']);
    $skipFk = isset($opts['no-fk']);

    try {
        $report = SqliteIntegrity::run($dbPath, $quickOnly, $skipFk);
    } catch (\Throwable $e) {
        fwrite(STDERR, $e->getMessage() . "\n");
        if (str_contains(strtolower($e->getMessage()), 'readonly')
            || str_contains(strtolower($e->getMessage()), 'read-only')) {
            fwrite(STDERR, "Hint: sudo -u apache php bin/latch db-check\n");
        }
        exit(1);
    }

    if (isset($opts['json'])) {
        fwrite(STDOUT, json_encode(SqliteIntegrity::toJson($dbPath, $report), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
    } else {
        fwrite(STDOUT, SqliteIntegrity::formatHuman($report) . "\n");
    }

    exit($report['ok'] ? 0 : 1);
}

function run_restore(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $storagePath = (string) $config->get('paths.storage');
    $sub = strtolower(trim((string) ($argv[2] ?? '')));

    if ($sub === 'list') {
        $backups = SiteRestore::listBackups($storagePath);
        if (isset($opts['json'])) {
            fwrite(STDOUT, json_encode(['backups' => $backups], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
            exit(0);
        }

        fwrite(STDOUT, $storagePath . "/backups/\n");
        foreach ($backups as $backup) {
            $parts = $backup['parts'] ?? [];
            $label = $parts !== []
                ? 'parts=[' . implode(', ', $parts) . ']'
                : '[' . implode(', ', $backup['contents']) . ']';
            $format = (string) ($backup['format'] ?? 'legacy');
            fwrite(STDOUT, sprintf(
                "  %s  %s  %s  %s  %s\n",
                $backup['name'],
                $backup['mtime_iso'],
                SiteRestore::formatBytes($backup['size_bytes']),
                $format,
                $label,
            ));
        }
        exit(0);
    }

    try {
        $archive = SiteRestore::resolveArchive(
            $storagePath,
            isset($opts['latest']) ? '1' : null,
            $opts['name'] ?? null,
            $opts['archive'] ?? null,
        );
    } catch (\Throwable $e) {
        fwrite(STDERR, $e->getMessage() . "\n");
        fwrite(STDERR, "Usage: php bin/latch restore list | restore --latest [--core-only|--plugins-only] [--with-config] [--force]\n");
        exit(1);
    }

    if (isset($opts['core-only']) && isset($opts['plugins-only'])) {
        fwrite(STDERR, "Use either --core-only or --plugins-only, not both.\n");
        exit(1);
    }

    $sourceRoot = realpath(LATCH_ROOT) ?: LATCH_ROOT;

    try {
        $result = SiteRestore::restore([
            'storage_path' => $storagePath,
            'source_root' => $sourceRoot,
            'db_path' => (string) $config->get('database.path'),
            'local_config_path' => LATCH_ROOT . '/config/local.php',
            'archive' => $archive,
            'with_config' => isset($opts['with-config']),
            'force' => isset($opts['force']),
            'dry_run' => isset($opts['dry-run']),
            'core_only' => isset($opts['core-only']),
            'plugins_only' => isset($opts['plugins-only']),
        ]);
    } catch (\Throwable $e) {
        $code = (int) $e->getCode();
        if ($code < 1 || $code > 255) {
            $code = 1;
        }
        fwrite(STDERR, $e->getMessage() . "\n");
        exit($code);
    }

    if (isset($opts['json'])) {
        fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
    } else {
        fwrite(STDOUT, $result['message'] . "\n");
    }
    exit(0);
}

function run_update(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');

    $orchestrator = new UpdateOrchestrator(
        $config,
        $opts,
        static fn (): array => collect_audit_issues($config),
        static fn (Database $db): CronService => build_cron_service($db),
    );

    exit($orchestrator->run());
}

function run_doctor(array $argv): void
{
    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $report = Doctor::run($config);

    if (isset($opts['json'])) {
        fwrite(STDOUT, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
    } else {
        fwrite(STDOUT, Doctor::formatHuman($report) . "\n");
    }

    exit($report['ok'] ? 0 : 1);
}

function run_test(array $argv): void
{
    $opts = parse_cli_options($argv);
    $suite = strtolower(trim((string) ($argv[2] ?? '')));

    if ($suite === '--smoke' || isset($opts['smoke'])) {
        run_test_smoke($opts);
        return;
    }

    if ($suite === '--security' || isset($opts['security'])) {
        run_test_security($opts);
        return;
    }

    if ($suite !== '' && !str_starts_with($suite, '--')) {
        fwrite(STDERR, "Unknown test suite: {$suite}\n");
        fwrite(STDERR, "Usage: php bin/latch test [--smoke|--security]\n");
        exit(1);
    }

    run_test_phpunit(isset($opts['filter']) ? (string) $opts['filter'] : null);
}

function run_test_phpunit(?string $filter = null): void
{
    $phpunit = LATCH_ROOT . '/vendor/bin/phpunit';
    if (!is_file($phpunit)) {
        fwrite(STDERR, "PHPUnit not found. Run: composer install --dev\n");
        exit(1);
    }

    if (!extension_loaded('dom') || !extension_loaded('xml')) {
        fwrite(STDERR, "PHPUnit needs php-xml (dom + xml extensions).\n");
        fwrite(STDERR, "Fallback: php bin/latch test --smoke\n");
        exit(1);
    }

    $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($phpunit)
        . ' -c ' . escapeshellarg(LATCH_ROOT . '/phpunit.xml.dist')
        . ' --testsuite Latch';
    if ($filter !== null && $filter !== '') {
        $cmd .= ' --filter ' . escapeshellarg($filter);
    }

    passthru($cmd, $code);
    exit($code);
}

/**
 * @param array<string, string> $opts
 */
function run_test_smoke(array $opts): void
{
    fwrite(STDOUT, "Smoke gate: operator-critical checks\n\n");

    if (!run_test_phpunit_suite('smoke', 'Smoke')) {
        run_test_smoke_fallback();
    }

    run_test_db_check_step();

    if (!run_test_audit_step()) {
        exit(1);
    }

    $baseUrl = resolve_live_test_url($opts, 'smoke');
    if ($baseUrl !== null) {
        fwrite(STDOUT, "==> HTTP smoke ({$baseUrl})\n");
        $code = run_web_smoke_harness($baseUrl, $opts);
        if ($code !== 0) {
            exit($code);
        }
    } else {
        fwrite(STDOUT, "==> HTTP smoke skipped (pass --url= or copy tests/smoke/config.example.php → config.local.php)\n");
    }

    if (is_file(LATCH_ROOT . '/tests/api/config.local.php')) {
        fwrite(STDOUT, "==> API smoke\n");
        $code = run_api_smoke_harness($opts);
        if ($code !== 0) {
            exit($code);
        }
    }

    fwrite(STDOUT, "\nSmoke gate passed.\n");
    exit(0);
}

/**
 * @param array<string, string> $opts
 */
function run_test_security(array $opts): void
{
    fwrite(STDOUT, "Security gate\n\n");

    if (!run_test_phpunit_suite('security', 'Security')) {
        fwrite(STDOUT, "PHPUnit unavailable — running audit + optional HTTP only\n");
    }

    $baseUrl = resolve_live_test_url($opts, 'smoke');
    if ($baseUrl !== null) {
        fwrite(STDOUT, "==> HTTP security probes ({$baseUrl})\n");
        $code = run_web_security_harness($baseUrl);
        if ($code !== 0) {
            exit($code);
        }
    } else {
        fwrite(STDOUT, "==> HTTP security skipped (pass --url= or tests/smoke/config.local.php)\n");
    }

    if (!run_test_audit_step()) {
        exit(1);
    }

    fwrite(STDOUT, "\nSecurity gate passed.\n");
    exit(0);
}

function run_test_phpunit_suite(string $suite, string $label): bool
{
    if (!extension_loaded('dom') || !extension_loaded('xml') || !is_file(LATCH_ROOT . '/vendor/bin/phpunit')) {
        return false;
    }

    $config = match ($suite) {
        'smoke' => LATCH_ROOT . '/phpunit-smoke.xml.dist',
        'security' => LATCH_ROOT . '/phpunit-security.xml.dist',
        default => LATCH_ROOT . '/phpunit.xml.dist',
    };
    if (!is_file($config)) {
        fwrite(STDERR, "PHPUnit config not found: {$config}\n");
        return false;
    }

    fwrite(STDOUT, "==> PHPUnit {$label} suite\n");
    $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(LATCH_ROOT . '/vendor/bin/phpunit')
        . ' -c ' . escapeshellarg($config)
        . ' --testsuite ' . escapeshellarg($suite);
    passthru($cmd, $code);
    if ($code !== 0) {
        fwrite(STDERR, "{$label} suite failed\n");
        exit($code);
    }

    return true;
}

function run_test_smoke_fallback(): void
{
    fwrite(STDOUT, "==> Built-in fallback (install php-xml for full PHPUnit)\n");
    require_pdo();
    $tmp = sys_get_temp_dir() . '/latch-smoke-' . bin2hex(random_bytes(4)) . '.sqlite';
    $pdo = new PDO('sqlite:' . $tmp);
    $pdo->exec('CREATE TABLE t (id INTEGER PRIMARY KEY)');
    $report = SqliteIntegrity::run($tmp);
    @unlink($tmp);
    if (!$report['ok']) {
        fwrite(STDERR, "SqliteIntegrity smoke failed\n");
        exit(1);
    }
    fwrite(STDOUT, "SqliteIntegrity: ok\n");
}

function run_test_db_check_step(): void
{
    if (!class_exists(PDO::class)) {
        return;
    }

    fwrite(STDOUT, "==> db-check\n");
    $config = new Config(LATCH_ROOT . '/config');
    $dbPath = (string) $config->get('database.path');
    if (!is_file($dbPath)) {
        fwrite(STDOUT, "Skipped (no database — fresh tree)\n");

        return;
    }

    $report = SqliteIntegrity::run($dbPath);
    fwrite(STDOUT, SqliteIntegrity::formatHuman($report) . "\n");
    if (!$report['ok']) {
        exit(1);
    }
}

function run_test_audit_step(): bool
{
    fwrite(STDOUT, "==> audit\n");
    $config = new Config(LATCH_ROOT . '/config');
    $issues = collect_audit_issues($config);
    if ($issues !== []) {
        Doctor::writeAuditFailure($issues);

        return false;
    }

    fwrite(STDOUT, "audit: OK\n");

    return true;
}

/**
 * @param array<string, string> $opts
 */
function resolve_live_test_url(array $opts, string $profile): ?string
{
    if (isset($opts['url']) && trim($opts['url']) !== '') {
        return rtrim(trim($opts['url']), '/');
    }

    foreach (['LATCH_TEST_URL', 'LATCH_URL'] as $envKey) {
        $fromEnv = getenv($envKey);
        if (is_string($fromEnv) && trim($fromEnv) !== '') {
            return rtrim(trim($fromEnv), '/');
        }
    }

    $configPath = LATCH_ROOT . '/tests/' . $profile . '/config.local.php';
    if (!is_file($configPath)) {
        return null;
    }

    $config = require $configPath;
    if (!is_array($config)) {
        return null;
    }

    $baseUrl = trim((string) ($config['base_url'] ?? ''));

    return $baseUrl !== '' ? rtrim($baseUrl, '/') : null;
}

/**
 * @param array<string, string> $opts
 */
function run_web_smoke_harness(string $baseUrl, array $opts): int
{
    if (!function_exists('curl_init')) {
        fwrite(STDERR, "HTTP smoke requires php-curl.\n");
        return 1;
    }

    $config = ['base_url' => $baseUrl];
    $configPath = LATCH_ROOT . '/tests/smoke/config.local.php';
    if (is_file($configPath)) {
        $local = require $configPath;
        if (is_array($local)) {
            $config = array_merge($local, $config);
        }
    }

    require LATCH_ROOT . '/tests/smoke/WebSmokeHarness.php';

    return (new WebSmokeHarness($config))->run();
}

function run_web_security_harness(string $baseUrl): int
{
    if (!function_exists('curl_init')) {
        fwrite(STDERR, "HTTP security probes require php-curl.\n");
        return 1;
    }

    require LATCH_ROOT . '/tests/security/WebSecurityHarness.php';

    return (new WebSecurityHarness($baseUrl))->run();
}

/**
 * @param array<string, string> $opts
 */
function run_api_smoke_harness(array $opts): int
{
    if (!function_exists('curl_init')) {
        fwrite(STDERR, "API smoke requires php-curl.\n");
        return 1;
    }

    $configPath = (string) ($opts['config'] ?? LATCH_ROOT . '/tests/api/config.local.php');
    $config = require $configPath;
    if (!is_array($config)) {
        fwrite(STDERR, "Invalid API config.\n");
        return 1;
    }

    if (isset($opts['url']) && $opts['url'] !== '') {
        $config['base_url'] = $opts['url'];
    }

    require LATCH_ROOT . '/tests/api/ApiHarness.php';

    return (new ApiHarness($config))->run();
}

function run_import(array $argv): void
{
    $sub = strtolower(trim((string) ($argv[2] ?? '')));
    if ($sub !== 'phpbb') {
        fwrite(STDERR, "Usage: php bin/latch import phpbb --bundle=PATH [--dry-run|--confirm]\n");
        fwrite(STDERR, "       php bin/latch import phpbb --export --from-mysql=DSN --out=PATH\n");
        exit(1);
    }

    $opts = parse_cli_options($argv);

    if (isset($opts['export'])) {
        run_import_phpbb_export($opts);
        return;
    }

    run_import_phpbb_bundle($opts);
}

/**
 * @param array<string, string> $opts
 */
function run_import_phpbb_export(array $opts): void
{
    $dsn = trim((string) ($opts['from-mysql'] ?? ''));
    $out = trim((string) ($opts['out'] ?? ''));
    if ($dsn === '' || $out === '') {
        fwrite(STDERR, "--export requires --from-mysql= and --out=\n");
        exit(1);
    }

    $prefix = trim((string) ($opts['prefix'] ?? 'phpbb_'));
    if ($prefix === '') {
        $prefix = 'phpbb_';
    }

    try {
        (new PhpbbReader())->exportToFile($dsn, $out, $prefix);
    } catch (\Throwable $e) {
        fwrite(STDERR, 'Export failed: ' . $e->getMessage() . "\n");
        exit(1);
    }

    fwrite(STDOUT, "Exported phpBB bundle → {$out}\n");
    exit(0);
}

/**
 * @param array<string, string> $opts
 */
function run_import_phpbb_bundle(array $opts): void
{
    $bundlePath = trim((string) ($opts['bundle'] ?? ''));
    if ($bundlePath === '') {
        fwrite(STDERR, "--bundle=PATH is required.\n");
        exit(1);
    }

    $dryRun = isset($opts['dry-run']);
    $confirm = isset($opts['confirm']);
    if ($dryRun === $confirm) {
        fwrite(STDERR, "Specify exactly one of --dry-run or --confirm.\n");
        exit(1);
    }

    require_pdo();

    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $reader = new PhpbbReader();
    $converter = new BbcodeConverter(phpbb_custom_bbcode_strategies());
    $importer = new PhpbbImporter($db, $converter);

    try {
        $bundle = $reader->loadBundleFile($bundlePath);
    } catch (\Throwable $e) {
        fwrite(STDERR, $e->getMessage() . "\n");
        exit(1);
    }

    $report = $confirm ? $importer->confirm($bundle) : $importer->dryRun($bundle);

    if (isset($opts['json'])) {
        fwrite(STDOUT, $report->toJson() . "\n");
    } else {
        fwrite(STDOUT, $report->toHuman() . "\n");
    }

    if ($confirm && $report->ok()) {
        fwrite(STDOUT, "\nRun: php bin/latch search-reindex\n");
    }

    exit($report->ok() ? 0 : 1);
}

/** @return array<string, string> */
function phpbb_custom_bbcode_strategies(): array
{
    $path = LATCH_ROOT . '/config/import-phpbb-bbcodes.php';
    if (!is_file($path)) {
        return [];
    }

    $data = require $path;

    return is_array($data) ? array_map('strval', $data) : [];
}

function run_backup(array $argv = []): void
{
    require_pdo();

    $opts = parse_cli_options($argv !== [] ? $argv : ($_SERVER['argv'] ?? []));
    $coreOnly = isset($opts['core-only']);
    $pluginsOnly = isset($opts['plugins-only']);
    if ($coreOnly && $pluginsOnly) {
        fwrite(STDERR, "Use either --core-only or --plugins-only, not both.\n");
        exit(1);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $storagePath = (string) $config->get('paths.storage');
    $result = SiteMaintenance::createBackup(
        $storagePath,
        (string) $config->get('database.path'),
        LATCH_ROOT . '/config/local.php',
        [
            'core' => !$pluginsOnly,
            'plugins' => !$coreOnly,
        ],
    );

    if (!$result['ok']) {
        fwrite(STDERR, $result['message'] . "\n");
        exit(1);
    }

    fwrite(STDOUT, $result['message'] . "\n");
}

function run_cache_clear(): void
{
    require_pdo();

    $config = new Config(LATCH_ROOT . '/config');
    $storagePath = (string) $config->get('paths.storage');
    $cleared = SiteMaintenance::clearCaches(new Cache($storagePath), $storagePath);

    fwrite(STDOUT, "Purged page cache entries: {$cleared['page_cache']}\n");
    fwrite(STDOUT, "Cleared Twig compile files: {$cleared['twig_files']}\n");
}

function build_mail_queue_service(Database $db): MailQueueService
{
    $config = new Config(LATCH_ROOT . '/config');
    $settings = new SettingRepository($db);

    return new MailQueueService(
        new Mail($config, $settings),
        $settings,
        new MailQueueRepository($db),
    );
}

function build_cron_service(Database $db): CronService
{
    $config = new Config(LATCH_ROOT . '/config');
    $settings = new SettingRepository($db);
    $registry = new \Latch\Core\Plugins\PluginRegistry(
        (string) $config->get('paths.plugins'),
        $settings,
    );

    return new CronService(
        $db,
        $settings,
        new PasswordResetRepository($db),
        new EmailVerificationRepository($db),
        new EmailChangeRepository($db),
        new UserSessionRepository($db),
        new UserRepository($db),
        new NotificationRepository($db),
        new RateLimiter($db),
        new OAuthTokenRepository($db),
        new ApiAuditLogRepository($db),
        new ReputationService($db, new UserRepository($db), $settings),
        build_mail_queue_service($db),
        plugin_audit_service_from_config($config),
        $registry,
    );
}

/**
 * @param array<string, int|bool> $stats
 */
function print_cron_stats(array $stats): void
{
    foreach ($stats as $key => $value) {
        fwrite(STDOUT, sprintf("%s: %s\n", $key, (string) $value));
    }
}

function run_cron(array $argv): void
{
    require_pdo();

    $config = new Config(LATCH_ROOT . '/config');
    $storagePath = (string) $config->get('paths.storage');
    if (SiteLock::isLocked($storagePath)) {
        fwrite(STDOUT, "Skipped: site lock is enabled (php bin/latch lock off).\n");
        exit(0);
    }

    $job = $argv[2] ?? '';
    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $cron = build_cron_service($db);

    try {
        match ($job) {
            'hourly' => print_cron_stats($cron->runHourly()),
            'daily' => print_cron_stats($cron->runDaily()),
            'weekly' => print_cron_stats($cron->runWeekly(isset($opts['audit']))),
            default => cron_usage_and_exit($job),
        };
    } catch (\Throwable $e) {
        fwrite(STDERR, 'Cron failed: ' . $e->getMessage() . "\n");
        exit(1);
    }
}

function cron_usage_and_exit(string $job): void
{
    if ($job !== '') {
        fwrite(STDERR, "Unknown cron job: {$job}\n");
    }

    fwrite(STDERR, "Usage: php bin/latch cron hourly|daily|weekly [--audit]\n");
    exit(1);
}

function run_lock(array $argv): void
{
    $action = strtolower(trim((string) ($argv[2] ?? 'status')));
    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $storagePath = (string) $config->get('paths.storage');

    match ($action) {
        'on', 'enable' => run_lock_on($storagePath, $opts),
        'off', 'disable' => run_lock_off($storagePath),
        'status' => run_lock_status($storagePath, $opts),
        default => lock_usage_and_exit($action),
    };
}

/**
 * @param array<string, string> $opts
 */
function run_lock_on(string $storagePath, array $opts): void
{
    if (SiteLock::isLocked($storagePath)) {
        fwrite(STDERR, "Site lock is already enabled.\n");
        exit(1);
    }

    $message = trim((string) ($opts['message'] ?? 'Site maintenance in progress.'));
    $result = SiteLock::enable($storagePath, $message, 'cli');

    fwrite(STDOUT, "Site lock enabled.\n");
    fwrite(STDOUT, "Unlock token: {$result['unlock_token']}\n");
    fwrite(STDOUT, "Unlock URL: /maintenance/unlock\n");
    fwrite(STDOUT, 'Or run: ' . SiteLock::cliUnlockHint() . "\n");
}

function run_lock_off(string $storagePath): void
{
    $result = SiteLock::disable($storagePath);

    if ($result === 'not_locked') {
        fwrite(STDOUT, "Site lock is not enabled.\n");
        exit(0);
    }

    if ($result === 'denied') {
        fwrite(STDERR, "Could not remove site lock (permission denied on storage/).\n");
        fwrite(STDERR, "The lock file is usually owned by the web server user.\n");
        fwrite(STDERR, 'Run: ' . SiteLock::cliUnlockHint() . "\n");
        fwrite(STDERR, "Or POST your unlock token at /maintenance/unlock (no shell access needed).\n");
        exit(1);
    }

    fwrite(STDOUT, "Site lock disabled — site is online.\n");
}

/**
 * @param array<string, string> $opts
 */
function run_lock_status(string $storagePath, array $opts = []): void
{
    $state = SiteLock::read($storagePath);
    if ($state === null) {
        fwrite(STDOUT, "Site lock: off\n");
        exit(0);
    }

    fwrite(STDOUT, "Site lock: on\n");
    fwrite(STDOUT, 'Enabled at: ' . $state['enabled_at'] . "\n");
    if ($state['enabled_by'] !== null) {
        fwrite(STDOUT, 'Enabled by: ' . $state['enabled_by'] . "\n");
    }
    fwrite(STDOUT, 'Message: ' . $state['message'] . "\n");

    if (isset($opts['show-token'])) {
        fwrite(STDOUT, 'Unlock token: ' . $state['unlock_token'] . "\n");
    }
}

function lock_usage_and_exit(string $action): void
{
    if ($action !== '') {
        fwrite(STDERR, "Unknown lock action: {$action}\n");
    }

    fwrite(STDERR, "Usage: php bin/latch lock on|off|status [--message=\"Updating…\"] [--show-token]\n");
    exit(1);
}

function run_logs(array $argv): void
{
    $sub = strtolower(trim((string) ($argv[2] ?? '')));
    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $viewer = LogViewer::fromConfig($config);

    match ($sub) {
        'list' => run_logs_list($viewer, $opts),
        'tail' => run_logs_tail($viewer, $opts),
        default => logs_usage_and_exit($sub),
    };
}

/**
 * @param array<string, string> $opts
 */
function run_logs_list(LogViewer $viewer, array $opts): void
{
    $sources = $viewer->listSources();

    if (isset($opts['json'])) {
        fwrite(STDOUT, json_encode(['sources' => $sources], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
        exit(0);
    }

    $groups = [];
    foreach ($sources as $source) {
        $group = (string) ($source['group'] ?? 'Other');
        $groups[$group][] = $source;
    }

    ksort($groups);
    foreach ($groups as $group => $items) {
        fwrite(STDOUT, $group . "\n");
        foreach ($items as $source) {
            $size = (int) ($source['size_bytes'] ?? 0);
            $sizeLabel = $size > 0 ? SiteRestore::formatBytes($size) : '-';
            fwrite(STDOUT, sprintf(
                "  %-18s  %-18s  %s  %s\n",
                (string) ($source['id'] ?? ''),
                (string) ($source['status'] ?? ''),
                $sizeLabel,
                (string) ($source['path'] ?? ''),
            ));
        }
        fwrite(STDOUT, "\n");
    }

    exit(0);
}

/**
 * @param array<string, string> $opts
 */
function run_logs_tail(LogViewer $viewer, array $opts): void
{
    try {
        $parsed = $viewer->parseRequestFilters($opts);
    } catch (LogViewerException $e) {
        fwrite(STDERR, $e->getMessage() . "\n");
        exit(logs_tail_exit_code($e));
    }

    $follow = isset($opts['follow']);
    if ($follow && isset($opts['cursor'])) {
        unset($parsed['cursor'], $parsed['fingerprint']);
    }

    try {
        $result = $viewer->tail(
            $parsed['source'],
            $parsed['limit'],
            $parsed['cursor'],
            $parsed['fingerprint'],
            $parsed['filters'],
        );
    } catch (LogViewerException $e) {
        fwrite(STDERR, $e->getMessage() . "\n");
        exit(logs_tail_exit_code($e));
    }

    if (isset($opts['json'])) {
        fwrite(STDOUT, json_encode([
            'source' => $parsed['source'],
            'lines' => $result['lines'],
            'parsed' => $result['parsed'],
            'next_cursor' => $result['next_cursor'],
            'fingerprint' => $result['fingerprint'],
            'rotated' => $result['rotated'],
            'scan_budget_exhausted' => $result['scan_budget_exhausted'],
            'matches_exhausted' => $result['matches_exhausted'],
            'bytes_scanned' => $result['bytes_scanned'],
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
        exit(0);
    }

    foreach ($result['lines'] as $line) {
        fwrite(STDOUT, $line . "\n");
    }

    logs_print_tail_hints($result);

    if (!$follow) {
        exit(0);
    }

    if (($result['source']['status'] ?? '') !== 'readable') {
        exit(0);
    }

    $path = (string) $result['source']['path'];
    $sourceId = $parsed['source'];
    $filters = $parsed['filters'];
    $offset = (int) $result['fingerprint']['size'];
    $lastMtime = (int) $result['fingerprint']['mtime'];
    $partial = '';

    if (function_exists('pcntl_signal')) {
        pcntl_signal(SIGINT, static function (): void {
            fwrite(STDOUT, "\n");
            exit(0);
        });
    }

    while (true) {
        if (function_exists('pcntl_signal_dispatch')) {
            pcntl_signal_dispatch();
        }

        sleep(2);

        if (!is_file($path)) {
            continue;
        }

        $stat = stat($path);
        if ($stat === false) {
            continue;
        }

        $size = (int) $stat['size'];
        $mtime = (int) $stat['mtime'];

        if ($size < $offset || $mtime !== $lastMtime) {
            fwrite(STDOUT, "# log rotated\n");
            $offset = 0;
            $partial = '';
            $lastMtime = $mtime;
            if ($size === 0) {
                continue;
            }
        }

        if ($size <= $offset) {
            $lastMtime = $mtime;
            continue;
        }

        $lines = logs_read_forward_lines($path, $offset, $partial);
        $lastMtime = $mtime;

        foreach ($lines as $line) {
            if ($line === '') {
                continue;
            }
            $formatted = $viewer->formatCliLine($sourceId, $line, $filters);
            if ($formatted === null) {
                continue;
            }
            fwrite(STDOUT, $formatted . "\n");
        }
    }
}

/**
 * @param array<string, mixed> $result
 */
function logs_print_tail_hints(array $result): void
{
    if ($result['rotated']) {
        fwrite(STDERR, "# log rotated — showing latest entries\n");
    }
    if ($result['scan_budget_exhausted']) {
        fwrite(STDERR, "# scan budget exhausted — older matches may exist\n");
    }
    if ($result['next_cursor'] !== null) {
        fwrite(STDERR, '# older content available; use --cursor=' . (int) $result['next_cursor'] . "\n");
        $fp = $result['fingerprint'];
        fwrite(STDERR, '# fingerprint: --fp-size=' . (int) $fp['size'] . ' --fp-mtime=' . (int) $fp['mtime'] . "\n");
    }
}

function logs_tail_exit_code(LogViewerException $e): int
{
    $message = $e->getMessage();
    if (str_contains($message, 'not readable')) {
        return 2;
    }

    return 1;
}

/**
 * @return list<string>
 */
function logs_read_forward_lines(string $path, int &$offset, string &$partial): array
{
    $stat = stat($path);
    if ($stat === false) {
        return [];
    }

    $size = (int) $stat['size'];
    if ($size <= $offset) {
        return [];
    }

    $handle = fopen($path, 'rb');
    if ($handle === false) {
        return [];
    }

    fseek($handle, $offset);
    $data = fread($handle, $size - $offset);
    fclose($handle);

    if (!is_string($data) || $data === '') {
        $offset = $size;

        return [];
    }

    $offset = $size;
    $content = $partial . $data;
    $lines = explode("\n", $content);
    $partial = (string) array_pop($lines);

    return array_map(static fn (string $line): string => rtrim($line, "\r"), $lines);
}

function logs_usage_and_exit(string $sub): void
{
    if ($sub !== '') {
        fwrite(STDERR, "Unknown logs subcommand: {$sub}\n");
    }

    fwrite(STDERR, "Usage: php bin/latch logs list [--json]\n");
    fwrite(STDERR, "       php bin/latch logs tail --source=ID [--lines=N] [--follow] \\\n");
    fwrite(STDERR, "         [--event=TYPE] [--ip=ADDR] [--username=NAME] \\\n");
    fwrite(STDERR, "         [--since=ISO] [--until=ISO] [--q=TEXT] \\\n");
    fwrite(STDERR, "         [--cursor=N] [--fp-size=N] [--fp-mtime=N] [--json]\n");
    exit(1);
}

function run_maintenance(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $storagePath = (string) $config->get('paths.storage');

    fwrite(STDOUT, "Running scheduled DB maintenance (cron daily)…\n");
    print_cron_stats(build_cron_service($db)->runDaily());

    if (isset($opts['clear-cache'])) {
        $cleared = SiteMaintenance::clearCaches(new Cache($storagePath), $storagePath);
        fwrite(STDOUT, "Purged page cache entries: {$cleared['page_cache']}\n");
        fwrite(STDOUT, "Cleared Twig compile files: {$cleared['twig_files']}\n");
    }

    if (isset($opts['vacuum'])) {
        $db->pdo()->exec('VACUUM');
        fwrite(STDOUT, "SQLite VACUUM complete.\n");
    }
}

function run_benchmark(array $argv): void
{
    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $baseUrl = rtrim((string) ($opts['url'] ?? $config->get('site.url', 'http://localhost')), '/');
    $iterations = max(1, (int) ($opts['iterations'] ?? 10));

    $paths = ['/', '/health'];
    $boards = new BoardRepository(latch_cli_database($config));
    foreach ($boards->all() as $board) {
        $paths[] = '/board/' . $board['slug'];
        break;
    }

    fwrite(STDOUT, "Benchmarking {$baseUrl} ({$iterations} iterations per path)\n\n");

    foreach ($paths as $path) {
        $times = [];
        for ($i = 0; $i < $iterations; $i++) {
            $start = microtime(true);
            $ch = curl_init($baseUrl . $path);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_NOBODY => false,
                CURLOPT_TIMEOUT => 30,
                CURLOPT_FOLLOWLOCATION => false,
            ]);
            curl_exec($ch);
            $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            $elapsed = (microtime(true) - $start) * 1000;
            if ($httpCode >= 200 && $httpCode < 400) {
                $times[] = $elapsed;
            }
        }

        if ($times === []) {
            fwrite(STDOUT, sprintf("%-20s  no successful responses\n", $path));
            continue;
        }

        sort($times);
        $p50 = $times[(int) floor(count($times) * 0.5)];
        $p95 = $times[(int) floor(count($times) * 0.95)];

        fwrite(STDOUT, sprintf("%-20s  p50=%.1fms  p95=%.1fms  n=%d\n", $path, $p50, $p95, count($times)));
    }
}

function run_install(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $configDir = LATCH_ROOT . '/config';
    $localPath = $configDir . '/local.php';
    $hasLocal = is_file($localPath);
    $config = new Config($configDir);
    $hasDb = $config->isInstalled();

    if ($hasDb) {
        $db = latch_cli_database($config);
        $existingUsers = (new UserRepository($db))->all();
        if ($existingUsers !== []) {
            fwrite(STDERR, "Already installed (users exist). Use migrate for schema updates.\n");
            exit(1);
        }

        fwrite(STDOUT, "Seeding install (database exists but has no users yet).\n");
    }

    if ($hasLocal) {
        if (!$hasDb) {
            fwrite(STDOUT, "Resuming install (config/local.php exists, database not created yet).\n");
        }
        $siteName = (string) $config->get('site.name', 'Latch');
        $siteUrl = (string) $config->get('site.url', 'http://localhost');
    } else {
        $siteUrl = (string) ($opts['url'] ?? prompt('Site URL', 'http://localhost'));
        $siteName = (string) ($opts['name'] ?? prompt('Site name', 'Latch'));

        $local = [
            'site' => [
                'name' => $siteName,
                'url' => rtrim($siteUrl, '/'),
            ],
            'security' => [
                'encryption_key' => generate_encryption_key_b64(),
            ],
        ];

        write_local_config($localPath, $local);
        fwrite(STDOUT, "Wrote {$localPath}\n");
    }

    $adminUser = (string) ($opts['admin-user'] ?? prompt('Admin username', 'admin'));
    $adminEmail = (string) ($opts['admin-email'] ?? prompt('Admin email', 'admin@localhost'));
    $adminPass = (string) ($opts['admin-pass'] ?? prompt_password('Admin password (min 8 chars)'));

    if (strlen($adminPass) < 8) {
        fwrite(STDERR, "Password must be at least 8 characters.\n");
        exit(1);
    }

    run_migrate();

    $config = new Config($configDir);
    $db = latch_cli_database($config);
    $users = new UserRepository($db);
    $boards = new BoardRepository($db);
    $settings = new SettingRepository($db);

    if ($users->findByUsername($adminUser) === null) {
        $admin = $users->create($adminUser, $adminEmail, $adminPass, 'admin');
        $users->markEmailVerified((int) $admin['id']);
        fwrite(STDOUT, "Admin user created: {$adminUser}\n");
    }

    $settings->set('site_name', $siteName);
    $settings->set('site_tagline', 'Run the room — not a datacenter');
    $settings->set(
        'footer_about',
        "Self-hosted forum software for operators who want to run the room — not a datacenter. One PHP app, one SQLite file; your posts, plugins, and backups stay on your disk.\n\n"
        . 'MIT-licensed, with a plugin catalog, guest caching, WAL-safe backups, and bin/latch for install, migrate, restore, and health checks.',
    );
    $settings->setBool('members_only', false);
    $settings->setBool('allow_registration', true);
    $settings->setBool('cache_enabled', true);
    $settings->set('cache_ttl_seconds', '120');
    $settings->set('max_tags_per_topic', '5');

    if (!isset($opts['no-seed-board']) && $boards->all() === []) {
        $board = $boards->create('General', 'Welcome to your new forum.');
        fwrite(STDOUT, "Default board created: {$board['name']} (/board/{$board['slug']})\n");
    }

    $config = new Config($configDir);
    if (encryption_key_missing($config)) {
        fwrite(STDOUT, "\nSecurity bootstrap (encryption key for admin 2FA)...\n");
        run_security_bootstrap();
    }

    fwrite(STDOUT, "\nInstall complete. Point your web server at:\n  " . LATCH_ROOT . "/public\n");
    fwrite(STDOUT, "\nSchedule maintenance (required for production):\n");
    fwrite(STDOUT, "  bash " . dirname(LATCH_ROOT) . "/scripts/install-cron.sh\n");
    fwrite(STDOUT, "  Or copy deploy/cron/latch.cron.example into your system crontab.\n");

    if (!isset($opts['no-configure']) && function_exists('stream_isatty') && stream_isatty(STDIN)) {
        $answer = strtolower(prompt('Configure Turnstile, mail, Cloudflare, and plugin secrets now?', 'n'));
        if (in_array($answer, ['y', 'yes'], true)) {
            run_configure(['latch', 'configure']);
        } else {
            fwrite(STDOUT, "Later: php bin/latch configure   (or sudo latch configure on RPM)\n");
        }
    } else {
        fwrite(STDOUT, "\nOptional config walkthrough: php bin/latch configure\n");
    }
}

/**
 * @return array<string, string>
 */
function parse_cli_options(array $argv): array
{
    $opts = [];

    foreach (array_slice($argv, 2) as $arg) {
        if (!str_starts_with($arg, '--')) {
            continue;
        }

        $arg = substr($arg, 2);
        if (str_contains($arg, '=')) {
            [$key, $value] = explode('=', $arg, 2);
            $opts[$key] = $value;
        } else {
            $opts[$arg] = '1';
        }
    }

    return $opts;
}

function prompt(string $label, string $default): string
{
    if (!function_exists('readline') || !stream_isatty(STDIN)) {
        return $default;
    }

    $value = readline("{$label} [{$default}]: ");

    return trim($value) !== '' ? trim($value) : $default;
}

function prompt_password(string $label): string
{
    if (!stream_isatty(STDIN)) {
        fwrite(STDERR, "{$label} required via --admin-pass when not interactive.\n");
        exit(1);
    }

    fwrite(STDOUT, "{$label}: ");
    system('stty -echo');
    $password = trim((string) fgets(STDIN));
    system('stty echo');
    fwrite(STDOUT, "\n");

    return $password;
}

function run_test_rss(): void
{
    require_pdo();

    $ranPhpunit = false;
    $phpunit = LATCH_ROOT . '/vendor/bin/phpunit';
    if (is_file($phpunit) && extension_loaded('dom') && extension_loaded('xml')) {
        $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($phpunit)
            . ' -c ' . escapeshellarg(LATCH_ROOT . '/phpunit.xml.dist')
            . ' ' . escapeshellarg(LATCH_ROOT . '/tests/RssFeedTest.php')
            . ' ' . escapeshellarg(LATCH_ROOT . '/tests/RssRepositoryTest.php');
        passthru($cmd, $code);
        if ($code !== 0) {
            fwrite(STDERR, "RSS PHPUnit tests failed.\n");
            exit($code);
        }
        fwrite(STDOUT, "RSS PHPUnit tests passed.\n");
        $ranPhpunit = true;
    }

    if (!$ranPhpunit) {
        fwrite(STDOUT, "Running built-in RSS checks (install php-xml for full PHPUnit suite).\n");
        rss_run_builtin_tests();
    }

    $config = new Config(LATCH_ROOT . '/config');
    if (!$config->isInstalled()) {
        fwrite(STDERR, "Database not installed — skipping live feed validation.\n");
        exit(1);
    }

    rss_run_live_validation($config);
}

function latch_db_writable_for_cli(string $dbPath): bool
{
    if (is_writable($dbPath)) {
        return true;
    }

    $dir = dirname($dbPath);

    return is_dir($dir) && is_writable($dir);
}

function rss_run_live_validation(Config $config): void
{
    $dbPath = (string) $config->get('database.path');
    if (!latch_db_writable_for_cli($dbPath)) {
        fwrite(STDOUT, "Database not writable for this user — validating feeds via HTTP.\n");
        rss_validate_live_feeds_via_http($config);

        return;
    }

    try {
        rss_validate_live_feeds_from_db($config, Database::openReadOnly($dbPath));
    } catch (Throwable $e) {
        fwrite(STDOUT, 'Live DB validation skipped (' . $e->getMessage() . ") — validating feeds via HTTP.\n");
        rss_validate_live_feeds_via_http($config);
    }
}

function rss_validate_live_feeds_from_db(Config $config, Database $db): void
{
    $rss = new RssRepository($db, new PostFormatter());
    $settings = new SettingRepository($db);
    $boards = new BoardRepository($db);
    $topicTags = new TopicTags();
    $tags = new TagRepository($db, $topicTags);

    $siteUrl = rtrim((string) $config->get('site.url', 'http://localhost'), '/');
    $siteName = $settings->get('site_name', (string) $config->get('site.name', 'Latch'));
    $membersOnly = $settings->getBool('members_only');

    if ($membersOnly) {
        fwrite(STDOUT, "members_only is enabled — skipping live guest feed validation.\n");

        return;
    }

    $siteTopics = $rss->recentTopicsForSite(50, false, false);
    $siteFeed = new RssFeed(
        $siteName . ' — All boards',
        $siteUrl . '/',
        'Recent topics across ' . $siteName,
        $siteUrl . '/feed.xml',
    );
    foreach ($siteTopics as $topic) {
        $topicTagsMap = $tags->forTopics([(int) $topic['id']]);
        $tagNames = array_map(
            static fn (array $tag): string => (string) $tag['name'],
            $topicTagsMap[(int) $topic['id']] ?? [],
        );
        $description = $rss->plainExcerpt((string) ($topic['first_post_body'] ?? ''));
        if ($tagNames !== []) {
            $description .= "\n\nTags: " . implode(', ', $tagNames);
        }
        $topicUrl = $siteUrl . '/topic/' . $topic['id'];
        $siteFeed->addItem(
            (string) $topic['title'],
            $topicUrl,
            $topicUrl,
            (string) $topic['last_post_at'],
            $description,
            (string) $topic['author_name'],
            $tagNames,
        );
    }
    rss_assert_valid_xml($siteFeed->render(), '/feed.xml', count($siteTopics));

    foreach ($boards->all() as $board) {
        if (!empty($board['requires_login_to_read'])) {
            continue;
        }

        $boardTopics = $rss->recentTopicsForBoard((int) $board['id'], 50);
        $boardFeed = new RssFeed(
            (string) $board['name'] . ' — ' . $siteName,
            $siteUrl . '/board/' . $board['slug'],
            (string) ($board['description'] !== '' ? $board['description'] : 'Topics in ' . $board['name']),
            $siteUrl . '/board/' . $board['slug'] . '/feed.xml',
        );
        foreach ($boardTopics as $topic) {
            $topicUrl = $siteUrl . '/topic/' . $topic['id'];
            $boardFeed->addItem(
                (string) $topic['title'],
                $topicUrl,
                $topicUrl,
                (string) $topic['last_post_at'],
                $rss->plainExcerpt((string) ($topic['first_post_body'] ?? '')),
                (string) $topic['author_name'],
            );
        }
        rss_assert_valid_xml(
            $boardFeed->render(),
            '/board/' . $board['slug'] . '/feed.xml',
            count($boardTopics),
        );
    }

    fwrite(STDOUT, "Live RSS feed XML validation passed.\n");
}

function run_post_announcements(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $topicId = (int) ($opts['topic'] ?? 0);
    $username = trim((string) ($opts['user'] ?? ''));
    $file = (string) ($opts['file'] ?? (LATCH_ROOT . '/data/changelog-announcements.json'));
    $dryRun = isset($opts['dry-run']);

    if ($topicId <= 0 || $username === '') {
        fwrite(STDERR, "Usage: php bin/latch post-announcements --topic=ID --user=NAME [--file=PATH] [--dry-run]\n");
        exit(1);
    }

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

    $raw = file_get_contents($file);
    $items = json_decode(is_string($raw) ? $raw : '', true);
    if (!is_array($items)) {
        fwrite(STDERR, "Invalid JSON in {$file}\n");
        exit(1);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $users = new UserRepository($db);
    $posts = new PostRepository($db);
    $topics = new TopicRepository($db, $posts);
    $search = build_search_repository($db);

    $user = $users->findByUsername($username);
    if ($user === null) {
        fwrite(STDERR, "User not found: {$username}\n");
        exit(1);
    }

    $topic = $topics->findById($topicId);
    if ($topic === null) {
        fwrite(STDERR, "Topic not found: {$topicId}\n");
        exit(1);
    }

    $existingMarkers = [];
    $stmt = $db->pdo()->prepare('SELECT body FROM posts WHERE topic_id = :topic_id AND deleted_at IS NULL');
    $stmt->execute(['topic_id' => $topicId]);
    foreach ($stmt->fetchAll() as $row) {
        if (preg_match('/<!-- latch-announcement:([a-z0-9._-]+) -->/', (string) $row['body'], $m)) {
            $existingMarkers[$m[1]] = true;
        }
    }

    $posted = 0;
    $skipped = 0;
    $baseTime = time();

    foreach ($items as $i => $item) {
        if (!is_array($item) || empty($item['body'])) {
            fwrite(STDERR, "Skipping invalid item at index {$i}\n");
            continue;
        }

        $id = (string) ($item['id'] ?? 'item-' . $i);
        $marker = '<!-- latch-announcement:' . $id . ' -->';
        if (isset($existingMarkers[$id])) {
            $skipped++;
            fwrite(STDOUT, "Skip (exists): {$id}\n");
            continue;
        }

        $body = $marker . "\n\n" . trim((string) $item['body']);

        if ($dryRun) {
            fwrite(STDOUT, "Would post [{$id}] " . substr($body, 0, 60) . "…\n");
            $posted++;
            continue;
        }

        $createdAt = gmdate('c', $baseTime + $i);
        $posts->create($topicId, (int) $user['id'], $body, $createdAt);
        $posted++;
        fwrite(STDOUT, "Posted: {$id}\n");
    }

    if ($dryRun) {
        fwrite(STDOUT, "Dry run — {$posted} post(s) would be created, {$skipped} skipped.\n");
        return;
    }

    if ($posted === 0) {
        fwrite(STDOUT, "Nothing new to post ({$skipped} already present).\n");
        return;
    }

    $topics->touchLastPost($topicId);
    if ($search->isEnabled()) {
        $search->indexTopic($topicId);
    }

    $storagePath = (string) $config->get('paths.storage');
    $cache = new Cache($storagePath);
    $cache->invalidateTag(Cache::tagTopic($topicId));
    $cache->invalidateTag(Cache::tagBoard((int) $topic['board_id']));
    $cache->invalidateTag(Cache::tagUser((int) $user['id']));
    $cache->invalidateTag(Cache::tagSite());

    fwrite(STDOUT, "Done — {$posted} post(s) created, {$skipped} skipped.\n");
}

function run_purge_users(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $idsRaw = trim((string) ($opts['ids'] ?? ''));
    $dryRun = isset($opts['dry-run']);

    if ($idsRaw === '') {
        fwrite(STDERR, "Usage: php bin/latch purge-users --ids=4,5,6 [--dry-run]\n");
        exit(1);
    }

    $ids = array_values(array_filter(array_map('intval', explode(',', $idsRaw)), static fn (int $id): bool => $id > 0));
    if ($ids === []) {
        fwrite(STDERR, "No valid user IDs provided.\n");
        exit(1);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $input = new Latch\Core\InputValidator($config);
    $users = new Latch\Models\UserRepository($db, $input);

    foreach ($ids as $id) {
        if ($dryRun) {
            fwrite(STDOUT, "Would purge user #{$id}\n");
            continue;
        }

        try {
            $users->purge($id);
            fwrite(STDOUT, "Purged user #{$id}\n");
        } catch (Throwable $e) {
            fwrite(STDERR, "Skip user #{$id}: {$e->getMessage()}\n");
        }
    }
}

function run_test_spam(): void
{
    $phpunit = LATCH_ROOT . '/vendor/bin/phpunit';
    if (!is_file($phpunit)) {
        fwrite(STDERR, "PHPUnit not found. Run composer install.\n");
        exit(1);
    }

    $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($phpunit)
        . ' -c ' . escapeshellarg(LATCH_ROOT . '/phpunit.xml.dist')
        . ' ' . escapeshellarg(LATCH_ROOT . '/tests/SpamGuardTest.php');
    passthru($cmd, $code);
    if ($code !== 0) {
        fwrite(STDERR, "Spam control PHPUnit tests failed.\n");
        exit($code);
    }

    fwrite(STDOUT, "Spam control PHPUnit tests passed.\n");
}

function run_test_webhooks(): void
{
    $phpunit = LATCH_ROOT . '/vendor/bin/phpunit';
    if (is_file($phpunit) && extension_loaded('dom') && extension_loaded('xml')) {
        $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($phpunit)
            . ' -c ' . escapeshellarg(LATCH_ROOT . '/phpunit.xml.dist')
            . ' ' . escapeshellarg(LATCH_ROOT . '/tests/WebhookRepositoryTest.php');
        passthru($cmd, $code);
        if ($code !== 0) {
            fwrite(STDERR, "Webhook PHPUnit tests failed.\n");
            exit($code);
        }

        fwrite(STDOUT, "Webhook PHPUnit tests passed.\n");

        return;
    }

    webhook_run_builtin_tests();
}

function webhook_run_builtin_tests(): void
{
    $path = sys_get_temp_dir() . '/latch-webhook-cli-' . bin2hex(random_bytes(4)) . '.sqlite';
    $db = new Database($path);
    $db->pdo()->exec(
        'CREATE TABLE webhooks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url TEXT NOT NULL,
            secret TEXT NOT NULL,
            events TEXT NOT NULL DEFAULT "[]",
            description TEXT,
            enabled INTEGER NOT NULL DEFAULT 1,
            created_at TEXT NOT NULL,
            last_delivery_at TEXT,
            last_status INTEGER
         );
         CREATE TABLE webhook_deliveries (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            webhook_id INTEGER NOT NULL,
            event TEXT NOT NULL,
            payload_json TEXT NOT NULL,
            response_code INTEGER,
            error TEXT,
            delivered_at TEXT NOT NULL,
            duration_ms INTEGER
         );'
    );

    $webhooks = new WebhookRepository($db);
    $id = $webhooks->create(
        'https://example.com/hook',
        'secret',
        [WebhookEvent::POST_CREATED],
        'CLI test',
    );

    if ($id <= 0) {
        @unlink($path);
        fwrite(STDERR, "Webhook create failed.\n");
        exit(1);
    }

    $matched = $webhooks->listEnabledForEvent(WebhookEvent::POST_CREATED);
    if (count($matched) !== 1 || ($matched[0]['url'] ?? '') !== 'https://example.com/hook') {
        @unlink($path);
        fwrite(STDERR, "listEnabledForEvent check failed.\n");
        exit(1);
    }

    $webhooks->setEnabled($id, false);
    if ($webhooks->listEnabledForEvent(WebhookEvent::POST_CREATED) !== []) {
        @unlink($path);
        fwrite(STDERR, "setEnabled check failed.\n");
        exit(1);
    }

    $payload = '{"event":"post.created","data":{}}';
    $webhooks->recordDelivery($id, WebhookEvent::POST_CREATED, $payload, 204, null, 12);
    $row = $webhooks->findById($id);
    if ($row === null || (int) ($row['last_status'] ?? 0) !== 204) {
        @unlink($path);
        fwrite(STDERR, "recordDelivery check failed.\n");
        exit(1);
    }

    $secret = 'test-secret';
    $body = '{"event":"user.registered","sent_at":"2026-07-03T00:00:00+00:00","data":{"user_id":1}}';
    $expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
    if ($expected !== 'sha256=' . hash_hmac('sha256', $body, $secret)) {
        @unlink($path);
        fwrite(STDERR, "HMAC signature check failed.\n");
        exit(1);
    }

    @unlink($path);
    fwrite(STDOUT, "Built-in webhook checks passed.\n");
}

function run_test_profiles(): void
{
    require_pdo();

    $ranPhpunit = false;
    $phpunit = LATCH_ROOT . '/vendor/bin/phpunit';
    if (is_file($phpunit) && extension_loaded('dom') && extension_loaded('xml')) {
        $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($phpunit)
            . ' -c ' . escapeshellarg(LATCH_ROOT . '/phpunit.xml.dist')
            . ' ' . escapeshellarg(LATCH_ROOT . '/tests/UserProfileTest.php');
        passthru($cmd, $code);
        if ($code !== 0) {
            fwrite(STDERR, "Public profile PHPUnit tests failed.\n");
            exit($code);
        }
        fwrite(STDOUT, "Public profile PHPUnit tests passed.\n");
        $ranPhpunit = true;
    }

    if (!$ranPhpunit) {
        fwrite(STDOUT, "Running built-in public profile checks (install php-xml for full PHPUnit suite).\n");
        profile_run_builtin_tests();
    }
}

function profile_run_builtin_tests(): void
{
    $path = sys_get_temp_dir() . '/latch-profile-cli-' . bin2hex(random_bytes(4)) . '.sqlite';
    $db = new Database($path);
    $db->pdo()->exec(
        'CREATE TABLE boards (
            id INTEGER PRIMARY KEY, slug TEXT, name TEXT, requires_login_to_read INTEGER DEFAULT 0,
            acl_read TEXT NOT NULL DEFAULT "guest", acl_topic TEXT NOT NULL DEFAULT "member",
            acl_reply TEXT NOT NULL DEFAULT "member"
         );
         CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, email TEXT, bio TEXT, role TEXT DEFAULT "member", created_at TEXT);
         CREATE TABLE topics (id INTEGER PRIMARY KEY, board_id INTEGER, user_id INTEGER, title TEXT, slug TEXT, deleted_at TEXT, last_post_at TEXT);
         CREATE TABLE posts (
            id INTEGER PRIMARY KEY, topic_id INTEGER, user_id INTEGER, body TEXT,
            created_at TEXT, deleted_at TEXT, quarantined_at TEXT, approval_status TEXT NOT NULL DEFAULT "approved"
         );
         INSERT INTO boards (id, slug, name, requires_login_to_read, acl_read) VALUES
            (1, "news", "News", 0, "guest"), (2, "staff", "Staff", 1, "member");
         INSERT INTO users (id, username, email, created_at, role) VALUES
            (1, "founder", "founder@test", "2026-01-01T00:00:00+00:00", "admin"),
            (2, "deleted_2", "deleted_2@deleted.local", "2026-01-02T00:00:00+00:00", "member");
         INSERT INTO topics (id, board_id, user_id, title, slug, last_post_at) VALUES
            (1, 1, 1, "Public topic", "public-topic", "2026-06-29T10:00:00+00:00"),
            (2, 2, 1, "Staff topic", "staff-topic", "2026-06-29T11:00:00+00:00");
         INSERT INTO posts (id, topic_id, user_id, body, created_at, quarantined_at) VALUES
            (1, 1, 1, "Hello world", "2026-06-29T10:00:00+00:00", NULL),
            (2, 2, 1, "Staff only post", "2026-06-29T11:00:00+00:00", NULL),
            (3, 1, 1, "Quarantined reply", "2026-06-29T12:00:00+00:00", "2026-06-29T12:05:00+00:00");'
    );

    $users = new UserRepository($db);
    $posts = new PostRepository($db);
    $active = $users->findById(1);
    $deleted = $users->findById(2);

    if ($active === null || $deleted === null || $users->isAnonymised($active) || !$users->isAnonymised($deleted)) {
        @unlink($path);
        fwrite(STDERR, "isAnonymised check failed.\n");
        exit(1);
    }

    $guestStats = $users->profileStats(1, false, false);
    if ($guestStats['post_count'] !== 1 || $guestStats['topic_count'] !== 1) {
        @unlink($path);
        fwrite(STDERR, "Guest profileStats check failed.\n");
        exit(1);
    }

    $guestPosts = $posts->recentPublicByUser(1, 10, false, false);
    if (count($guestPosts) !== 1 || ($guestPosts[0]['body'] ?? '') !== 'Hello world') {
        @unlink($path);
        fwrite(STDERR, "Guest recentPublicByUser check failed.\n");
        exit(1);
    }

    @unlink($path);
    fwrite(STDOUT, "Built-in public profile checks passed.\n");
}

function rss_run_builtin_tests(): void
{
    if (RssFeed::escape('<a>&"\'') !== '&lt;a&gt;&amp;&quot;&apos;') {
        fwrite(STDERR, "RssFeed::escape failed.\n");
        exit(1);
    }

    $feed = new RssFeed('T', 'https://example.test/', 'D', 'https://example.test/feed.xml');
    $feed->addItem('Item', 'https://example.test/t/1', 'https://example.test/t/1', '2026-06-29T10:00:00+00:00', 'Body');
    $xml = $feed->render();

    if (!str_starts_with($xml, '<?xml') || !str_contains($xml, '<rss version="2.0"')) {
        fwrite(STDERR, "RSS render structure check failed.\n");
        exit(1);
    }

    if (substr_count($xml, '<item>') !== 1) {
        fwrite(STDERR, "RSS item count check failed.\n");
        exit(1);
    }

    $path = sys_get_temp_dir() . '/latch-rss-cli-' . bin2hex(random_bytes(4)) . '.sqlite';
    $db = new Database($path);
    $db->pdo()->exec(
        'CREATE TABLE boards (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, description TEXT DEFAULT "", requires_login_to_read INTEGER DEFAULT 0);
         CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, email TEXT, avatar_url TEXT);
         CREATE TABLE topics (id INTEGER PRIMARY KEY, board_id INTEGER, user_id INTEGER, title TEXT, slug TEXT, deleted_at TEXT, last_post_at TEXT);
         CREATE TABLE posts (id INTEGER PRIMARY KEY, topic_id INTEGER, user_id INTEGER, body TEXT, created_at TEXT, deleted_at TEXT, quarantined_at TEXT);
         INSERT INTO boards (id, slug, name) VALUES (1, "news", "News");
         INSERT INTO users (id, username, email) VALUES (1, "tester", "t@test");
         INSERT INTO topics (id, board_id, user_id, title, slug, last_post_at) VALUES (1, 1, 1, "Hello", "hello", "2026-06-29T10:00:00+00:00");
         INSERT INTO posts (id, topic_id, user_id, body, created_at) VALUES (1, 1, 1, "Post body", "2026-06-29T10:00:00+00:00");'
    );
    $rss = new RssRepository($db, new PostFormatter());
    if (count($rss->recentTopicsForSite(10, false, false)) !== 1) {
        @unlink($path);
        fwrite(STDERR, "RssRepository site query failed.\n");
        exit(1);
    }
    @unlink($path);

    fwrite(STDOUT, "Built-in RSS checks passed.\n");
}

function rss_assert_valid_xml(string $xml, string $label, ?int $expectedItems = null): void
{
    if (extension_loaded('dom')) {
        $doc = new DOMDocument();
        if (!$doc->loadXML($xml)) {
            fwrite(STDERR, "Invalid XML for {$label}\n");
            exit(1);
        }

        $items = $doc->getElementsByTagName('item')->length;
    } else {
        if (!str_starts_with(trim($xml), '<?xml') || !str_contains($xml, '<rss version="2.0"')) {
            fwrite(STDERR, "Invalid RSS structure for {$label}\n");
            exit(1);
        }

        $items = substr_count($xml, '<item>');
    }

    if ($expectedItems !== null && $items !== $expectedItems) {
        fwrite(STDERR, "Expected {$expectedItems} item(s) in {$label}, got {$items}\n");
        exit(1);
    }

    fwrite(STDOUT, "  ✓ {$label} — {$items} item(s)\n");
}

function rss_validate_live_feeds_via_http(Config $config): void
{
    $siteUrl = rtrim((string) $config->get('site.url', 'http://localhost'), '/');
    $feedUrl = $siteUrl . '/feed.xml';

    $context = stream_context_create([
        'http' => [
            'timeout' => 15,
            'ignore_errors' => true,
            'header' => "User-Agent: Latch-CLI-RSS-Test/1.0\r\n",
        ],
    ]);

    $xml = @file_get_contents($feedUrl, false, $context);
    if (!is_string($xml) || $xml === '') {
        fwrite(STDERR, "Could not fetch {$feedUrl}\n");
        exit(1);
    }

    rss_assert_valid_xml($xml, '/feed.xml');
    fwrite(STDOUT, "Live RSS feed HTTP validation passed.\n");
}

function plugin_auditor_from_config(Config $config): \Latch\Core\Plugins\PluginAuditor
{
    return new \Latch\Core\Plugins\PluginAuditor(
        LATCH_ROOT,
        (string) $config->get('paths.plugins'),
        (string) $config->get('paths.storage'),
    );
}

function plugin_audit_service_from_config(Config $config): \Latch\Core\Plugins\PluginAuditService
{
    $storagePath = (string) $config->get('paths.storage');

    return new \Latch\Core\Plugins\PluginAuditService(
        plugin_auditor_from_config($config),
        new \Latch\Core\Plugins\PluginAuditCache($storagePath . '/cache/plugin-audits'),
    );
}

function plugin_resolve_directory(Config $config, string $slug): ?string
{
    return \Latch\Core\Plugins\PluginManifest::resolveDirectory(
        (string) $config->get('paths.plugins'),
        $slug,
    );
}

function plugin_is_ignored_by_slug(Config $config, string $slug): bool
{
    $dir = plugin_resolve_directory($config, $slug);
    if ($dir === null) {
        return false;
    }

    try {
        return \Latch\Core\Plugins\PluginManifestStore::isIgnored($dir);
    } catch (\Throwable) {
        return false;
    }
}

function plugin_find_manifest(Config $config, string $slug): ?\Latch\Core\Plugins\PluginManifest
{
    foreach (\Latch\Core\Plugins\PluginRegistry::discoverInDirectory((string) $config->get('paths.plugins')) as $manifest) {
        if ($manifest->slug === $slug) {
            return $manifest;
        }
    }

    return null;
}

function plugin_bust_cache_after_toggle(Config $config, string $slug): void
{
    $storagePath = (string) $config->get('paths.storage');
    $cache = new Cache($storagePath);
    $cache->invalidateTag(Cache::tagPlugin($slug));
    SiteMaintenance::clearCaches($cache, $storagePath);
}

function plugin_registry_from_config(Config $config): \Latch\Core\Plugins\PluginRegistry
{
    $db = latch_cli_database($config);

    return new \Latch\Core\Plugins\PluginRegistry(
        (string) $config->get('paths.plugins'),
        new SettingRepository($db),
    );
}

function plugin_catalog_from_config(Config $config): \Latch\Core\Plugins\PluginCatalog
{
    $storagePath = (string) $config->get('paths.storage');

    return new \Latch\Core\Plugins\PluginCatalog(
        $storagePath . '/cache/plugin-catalog.json',
        (string) ($config->get('plugin_catalog.catalog_url') ?? \Latch\Core\Plugins\PluginCatalog::DEFAULT_CATALOG_URL),
        (string) ($config->get('plugin_catalog.release_repo') ?? \Latch\Core\Plugins\PluginCatalog::DEFAULT_RELEASE_REPO),
        (int) ($config->get('plugin_catalog.cache_ttl_seconds') ?? 3600),
    );
}

function plugin_catalog_installer_from_config(Config $config): \Latch\Core\Plugins\PluginCatalogInstaller
{
    $storagePath = (string) $config->get('paths.storage');
    $pluginsPath = (string) $config->get('paths.plugins');
    $catalog = plugin_catalog_from_config($config);

    return new \Latch\Core\Plugins\PluginCatalogInstaller(
        new \Latch\Core\Plugins\PluginInstaller($pluginsPath, $storagePath),
        plugin_audit_service_from_config($config),
        plugin_registry_from_config($config),
        new \Latch\Core\Plugins\PluginReleaseDownloader($catalog->releaseRepo()),
        $storagePath,
    );
}

function latch_cli_database(Config $config): Database
{
    try {
        return Database::fromConfig($config);
    } catch (RuntimeException $e) {
        $msg = strtolower($e->getMessage());
        if (str_contains($msg, 'read-only') || str_contains($msg, 'readonly')) {
            fwrite(STDERR, $e->getMessage() . "\n");
            fwrite(STDERR, "Hint: the SQLite file is usually owned by the web server user. Try:\n");
            fwrite(STDERR, "  sudo -u apache php bin/latch ...\n");
            exit(1);
        }

        throw $e;
    }
}

function emit_plugin_audit_report(\Latch\Core\Plugins\PluginAuditReport $report, bool $asJson): void
{
    if ($asJson) {
        fwrite(STDOUT, json_encode($report->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
    } else {
        fwrite(STDOUT, $report->toHuman());
    }
}

function run_plugin_audit(array $argv): void
{
    $opts = parse_cli_options($argv);
    $target = '';
    foreach (array_slice($argv, 2) as $arg) {
        if (str_starts_with($arg, '--')) {
            continue;
        }
        $target = trim($arg);
        break;
    }

    if ($target === '') {
        fwrite(STDERR, "Usage: php bin/latch plugin-audit <path|slug> [--json]\n");
        exit(1);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $auditor = plugin_auditor_from_config($config);
    $auditService = plugin_audit_service_from_config($config);

    if (plugin_is_ignored_by_slug($config, $target)) {
        fwrite(STDERR, "Plugin is ignored. Run: php bin/latch plugin unignore {$target}\n");
        exit(1);
    }

    try {
        $manifest = plugin_find_manifest($config, $target);
        if ($manifest !== null) {
            $result = $auditService->getOrScan($manifest, true);
            $report = $result['report'];
        } else {
            $report = $auditor->auditTarget($target);
        }
    } catch (\Throwable $e) {
        fwrite(STDERR, $e->getMessage() . "\n");
        exit(1);
    }

    emit_plugin_audit_report($report, isset($opts['json']));
    if (!$report->passed()) {
        exit(1);
    }
}

function run_plugin(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $action = $argv[2] ?? 'list';
    $slug = trim((string) ($argv[3] ?? ''));

    $config = new Config(LATCH_ROOT . '/config');
    $latchVersion = (string) $config->get('app.version', '0.3.0');
    $auditService = plugin_audit_service_from_config($config);

    if ($action === 'audit') {
        if ($slug === '') {
            fwrite(STDERR, "Usage: php bin/latch plugin audit <path|slug> [--json]\n");
            exit(1);
        }

        if (plugin_is_ignored_by_slug($config, $slug)) {
            fwrite(STDERR, "Plugin is ignored. Run: php bin/latch plugin unignore {$slug}\n");
            exit(1);
        }

        try {
            $manifest = plugin_find_manifest($config, $slug);
            if ($manifest !== null) {
                $result = $auditService->getOrScan($manifest, true);
                $report = $result['report'];
            } else {
                $report = plugin_auditor_from_config($config)->auditTarget($slug);
            }
        } catch (\Throwable $e) {
            fwrite(STDERR, $e->getMessage() . "\n");
            exit(1);
        }

        emit_plugin_audit_report($report, isset($opts['json']));
        if (!$report->passed()) {
            exit(1);
        }

        return;
    }

    if ($action === 'install') {
        $source = $slug;
        if ($source === '') {
            fwrite(STDERR, "Usage: php bin/latch plugin install <directory|zip>\n");
            exit(1);
        }

        $installer = new \Latch\Core\Plugins\PluginInstaller(
            (string) $config->get('paths.plugins'),
            (string) $config->get('paths.storage'),
        );

        try {
            $manifest = $installer->installFromSource($source);
        } catch (\Throwable $e) {
            fwrite(STDERR, $e->getMessage() . "\n");
            exit(1);
        }

        try {
            $result = $auditService->getOrScan($manifest, true);
            $report = $result['report'];
        } catch (\Throwable $e) {
            $installer->removeInstalled($manifest->slug);
            fwrite(STDERR, $e->getMessage() . "\n");
            exit(1);
        }

        if (!$report->passed()) {
            $installer->removeInstalled($manifest->slug);
            $auditService->forget($manifest->slug);
            fwrite(STDERR, $report->toHuman());
            fwrite(STDERR, "Install rolled back — fix critical audit findings and retry.\n");
            exit(1);
        }

        $registry = plugin_registry_from_config($config);
        $registry->disable($manifest->slug);

        fwrite(STDOUT, "Installed plugin: {$manifest->slug} v{$manifest->version} (disabled)\n");
        fwrite(STDOUT, $report->toHuman());
        if ($report->warnCount() > 0) {
            fwrite(STDOUT, "Review warnings before: php bin/latch plugin enable {$manifest->slug}\n");
        } else {
            fwrite(STDOUT, "Enable when ready: php bin/latch plugin enable {$manifest->slug}\n");
        }

        return;
    }

    if ($action === 'update') {
        if ($slug === '') {
            fwrite(STDERR, "Usage: php bin/latch plugin update <slug> [--from <directory|zip>]\n");
            exit(1);
        }

        if (plugin_is_ignored_by_slug($config, $slug)) {
            fwrite(STDERR, "Plugin is ignored. Run: php bin/latch plugin unignore {$slug}\n");
            exit(1);
        }

        $manifest = plugin_find_manifest($config, $slug);
        if ($manifest === null) {
            fwrite(STDERR, "Plugin not found: {$slug}\n");
            exit(1);
        }

        $fromSource = isset($opts['from']) ? trim((string) $opts['from']) : '';
        $pluginsPath = (string) $config->get('paths.plugins');
        $storagePath = (string) $config->get('paths.storage');
        $installer = new \Latch\Core\Plugins\PluginInstaller($pluginsPath, $storagePath);
        $registry = plugin_registry_from_config($config);
        $wasEnabled = $registry->isEnabled($slug);
        $previousVersion = $manifest->version;

        if ($fromSource !== '') {
            try {
                $upgrade = $installer->upgradeFromSource($fromSource, $slug);
            } catch (\Throwable $e) {
                fwrite(STDERR, $e->getMessage() . "\n");
                exit(1);
            }

            try {
                $auditService->forget($slug);
                $result = $auditService->getOrScan($upgrade->manifest, true);
                $report = $result['report'];
            } catch (\Throwable $e) {
                $upgrade->rollback();
                fwrite(STDERR, $e->getMessage() . "\n");
                exit(1);
            }

            if (!$report->passed()) {
                $upgrade->rollback();
                $auditService->forget($slug);
                fwrite(STDERR, $report->toHuman());
                fwrite(STDERR, "Update rolled back — fix critical audit findings and retry.\n");
                exit(1);
            }

            $upgrade->commit();
            $manifest = $upgrade->manifest;
        } else {
            $catalog = plugin_catalog_from_config($config);
            $catalogData = $catalog->load(true);
            if ($catalogData === null) {
                fwrite(STDERR, "Could not load the plugin catalog. Use --from <dir|zip> or check outbound HTTPS.\n");
                exit(1);
            }

            $entry = $catalog->findUpdateEntry($slug, $manifest->version, true);
            if ($entry === null) {
                fwrite(STDERR, "No catalog update for {$slug} (installed v{$manifest->version}).\n");
                exit(1);
            }

            if (!$entry->isCompatibleWith($latchVersion)) {
                fwrite(STDERR, "Plugin {$slug} requires Latch >= {$entry->minLatchVersion} (running {$latchVersion})\n");
                exit(1);
            }

            try {
                $updateResult = plugin_catalog_installer_from_config($config)->update($entry, $catalogData['release']);
            } catch (\Throwable $e) {
                fwrite(STDERR, $e->getMessage() . "\n");
                exit(1);
            }

            $manifest = $updateResult['manifest'];
            $previousVersion = $updateResult['previous_version'];
            $wasEnabled = $updateResult['was_enabled'];
        }

        if ($wasEnabled) {
            $dbManager = new \Latch\Core\Plugins\PluginDatabaseManager(
                $storagePath,
                \Latch\Core\Database::sqliteOptionsFromConfig($config),
            );

            try {
                $applied = $dbManager->migrate($manifest);
                if ($applied > 0) {
                    fwrite(STDOUT, "Applied {$applied} plugin database migration(s).\n");
                }
            } catch (\Throwable $e) {
                fwrite(STDERR, 'Plugin database migration failed: ' . $e->getMessage() . "\n");
                exit(1);
            }
        }

        plugin_bust_cache_after_toggle($config, $slug);
        fwrite(STDOUT, "Updated plugin: {$slug} v{$previousVersion} → v{$manifest->version}\n");
        if ($wasEnabled) {
            fwrite(STDOUT, "Plugin remains enabled.\n");
        }
        fwrite(STDOUT, "Guest page and Twig cache cleared.\n");

        return;
    }

    if ($action === 'remove') {
        if ($slug === '') {
            fwrite(STDERR, "Usage: php bin/latch plugin remove <slug> --confirm [--purge-storage]\n");
            exit(1);
        }

        if (!isset($opts['confirm'])) {
            fwrite(STDERR, "Refusing to remove {$slug} without --confirm\n");
            exit(1);
        }

        $installer = new \Latch\Core\Plugins\PluginInstaller(
            (string) $config->get('paths.plugins'),
            (string) $config->get('paths.storage'),
        );

        try {
            $installer->removeInstalled($slug, isset($opts['purge-storage']));
        } catch (\Throwable $e) {
            fwrite(STDERR, $e->getMessage() . "\n");
            exit(1);
        }

        $registry = plugin_registry_from_config($config);
        $registry->disable($slug);
        $auditService->forget($slug);

        fwrite(STDOUT, "Removed plugin: {$slug}\n");
        if (isset($opts['purge-storage'])) {
            fwrite(STDOUT, "Purged storage/plugins/{$slug}/\n");
        }
        fwrite(STDOUT, "Clear page/Twig cache if the site was already running.\n");

        return;
    }

    if ($action === 'enable') {
        if ($slug === '') {
            fwrite(STDERR, "Usage: php bin/latch plugin enable <slug> [--force]\n");
            exit(1);
        }

        if (plugin_is_ignored_by_slug($config, $slug)) {
            fwrite(STDERR, "Plugin is ignored. Run: php bin/latch plugin unignore {$slug}\n");
            exit(1);
        }

        $manifest = plugin_find_manifest($config, $slug);
        if ($manifest === null) {
            fwrite(STDERR, "Plugin not found: {$slug}\n");
            exit(1);
        }

        if (!$manifest->isCompatibleWith($latchVersion)) {
            fwrite(STDERR, "Plugin {$slug} requires Latch >= {$manifest->minLatchVersion} (running {$latchVersion})\n");
            exit(1);
        }

        try {
            $result = $auditService->getOrScan($manifest, true);
            $report = $result['report'];
        } catch (\Throwable $e) {
            fwrite(STDERR, $e->getMessage() . "\n");
            exit(1);
        }

        if (!$report->enableAllowed()) {
            fwrite(STDERR, $report->toHuman());
            if (!isset($opts['force'])) {
                fwrite(STDERR, "Audit failed or enable blocked. Fix findings or re-run with --force (logged to audit_log when DB is writable).\n");
                exit(1);
            }

            fwrite(STDERR, "Enabling anyway (--force). Review findings above.\n");
        }

        $db = latch_cli_database($config);
        $settings = new \Latch\Models\SettingRepository($db);
        $registry = new \Latch\Core\Plugins\PluginRegistry(
            (string) $config->get('paths.plugins'),
            $settings,
        );

        if (!$report->enableAllowed() && isset($opts['force'])) {
            try {
                $auditLog = new \Latch\Models\AuditLogRepository($db);
                $auditLog->record(
                    0,
                    'plugin.enable_forced',
                    'plugin',
                    null,
                    'cli',
                    [
                        'slug' => $slug,
                        'critical' => $report->criticalCount(),
                        'warn' => $report->warnCount(),
                        'findings' => array_map(
                            static fn (\Latch\Core\Plugins\PluginAuditFinding $f): array => $f->toArray(),
                            $report->findings,
                        ),
                    ],
                );
            } catch (\Throwable) {
                // Best-effort when audit_log write fails.
            }
        }

        $dbManager = new \Latch\Core\Plugins\PluginDatabaseManager(
            (string) $config->get('paths.storage'),
            \Latch\Core\Database::sqliteOptionsFromConfig($config),
        );

        try {
            $applied = $dbManager->migrate($manifest);
            if ($applied > 0) {
                fwrite(STDOUT, "Applied {$applied} plugin database migration(s).\n");
            }
        } catch (\Throwable $e) {
            fwrite(STDERR, 'Plugin database migration failed: ' . $e->getMessage() . "\n");
            exit(1);
        }

        $pluginStorageDir = $dbManager->storageDir($slug);
        if (function_exists('posix_geteuid') && posix_geteuid() === 0
            && !\Latch\Core\Plugins\PluginStoragePermissions::ensureWritable($pluginStorageDir)) {
            fwrite(STDERR, "Warning: could not chown {$pluginStorageDir} for the web server. Admin settings save may fail.\n");
            fwrite(STDERR, "Fix: sudo latch fix-perms\n");
        }

        $enabled = $registry->enabledSlugs();
        if (!in_array($slug, $enabled, true)) {
            $enabled[] = $slug;
        }

        $registry->setEnabledSlugs($enabled);
        plugin_bust_cache_after_toggle($config, $slug);
        fwrite(STDOUT, "Enabled plugin: {$slug}\n");
        fwrite(STDOUT, "Guest page and Twig cache cleared.\n");

        return;
    }

    $db = latch_cli_database($config);
    $settings = new \Latch\Models\SettingRepository($db);
    $registry = new \Latch\Core\Plugins\PluginRegistry(
        (string) $config->get('paths.plugins'),
        $settings,
    );

    if ($action === 'list') {
        $includeIgnored = isset($opts['all']);
        if ($includeIgnored) {
            $enabled = array_fill_keys($registry->enabledSlugs(), true);
            $rows = [];
            foreach (\Latch\Core\Plugins\PluginRegistry::discoverAllInDirectory((string) $config->get('paths.plugins')) as $manifest) {
                $rows[] = [
                    'manifest' => $manifest,
                    'enabled' => isset($enabled[$manifest->slug]),
                ];
            }
        } else {
            $rows = $registry->listWithStatus();
        }

        if ($rows === []) {
            fwrite(STDOUT, "No plugins found in " . $config->get('paths.plugins') . "\n");
            return;
        }

        foreach ($rows as $row) {
            $manifest = $row['manifest'];
            $state = $row['enabled'] ? 'enabled' : 'disabled';
            if ($manifest->ignored) {
                $state = 'ignored';
            }
            $compat = $manifest->isCompatibleWith($latchVersion) ? 'ok' : 'incompatible';
            fwrite(STDOUT, sprintf(
                "%-14s v%-7s %-10s %-12s %s\n",
                $manifest->slug,
                $manifest->version,
                $state,
                $compat,
                $manifest->name,
            ));
        }

        return;
    }

    if ($action === 'ignore' || $action === 'unignore') {
        if ($slug === '') {
            fwrite(STDERR, "Usage: php bin/latch plugin {$action} <slug>\n");
            exit(1);
        }

        $dir = plugin_resolve_directory($config, $slug);
        if ($dir === null) {
            fwrite(STDERR, "Plugin not found: {$slug}\n");
            exit(1);
        }

        $ignored = $action === 'ignore';
        try {
            \Latch\Core\Plugins\PluginManifestStore::setIgnored($dir, $ignored);
        } catch (\Throwable $e) {
            fwrite(STDERR, $e->getMessage() . "\n");
            exit(1);
        }

        $registry->disable($slug);
        $auditService->forget($slug);

        if ($ignored) {
            fwrite(STDOUT, "Ignored plugin: {$slug} (removed from discovery, audits, and enable list)\n");
        } else {
            fwrite(STDOUT, "Unignored plugin: {$slug} (visible again — run plugin-audit before enable)\n");
        }

        return;
    }

    if ($action === 'disable') {
        if ($slug === '') {
            fwrite(STDERR, "Usage: php bin/latch plugin disable <slug>\n");
            exit(1);
        }

        if (plugin_find_manifest($config, $slug) === null) {
            fwrite(STDERR, "Plugin not found: {$slug}\n");
            exit(1);
        }

        $enabled = array_values(array_filter(
            $registry->enabledSlugs(),
            static fn (string $s): bool => $s !== $slug,
        ));
        $registry->setEnabledSlugs($enabled);
        plugin_bust_cache_after_toggle($config, $slug);
        fwrite(STDOUT, "Disabled plugin: {$slug}\n");
        fwrite(STDOUT, "Guest page and Twig cache cleared.\n");

        return;
    }

    fwrite(STDERR, "Usage: php bin/latch plugin list [--all]|install <dir|zip>|remove <slug> --confirm|audit <path|slug>|enable <slug>|disable <slug>|ignore <slug>|unignore <slug>\n");
    exit(1);
}

function run_reputation_recompute(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $users = new \Latch\Models\UserRepository($db);
    $settings = new \Latch\Models\SettingRepository($db);
    $service = new \Latch\Core\ReputationService($db, $users, $settings);

    if (isset($opts['user'])) {
        $userId = (int) $opts['user'];
        $result = $service->computeForUser($userId);
        fwrite(STDOUT, "User #{$userId}: rank " . ($result['rank'] ?? 'n/a')
            . ', score ' . round($result['score'], 2) . "\n");

        return;
    }

    $count = $service->recomputeAll();
    fwrite(STDOUT, "Recomputed reputation for {$count} member(s).\n");
}

function run_search_reindex(): void
{
    require_pdo();

    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $result = SiteMaintenance::reindexSearch(build_search_repository($db));

    if (!$result['ok']) {
        fwrite(STDERR, $result['message'] . "\n");
        exit(1);
    }

    fwrite(STDOUT, $result['message'] . "\n");
}

function maybe_auto_search_reindex(Database $db): void
{
    $search = build_search_repository($db);
    if (!$search->isEnabled()) {
        return;
    }

    $count = (int) $db->pdo()->query('SELECT COUNT(*) FROM search_index')->fetchColumn();
    $topics = (int) $db->pdo()->query('SELECT COUNT(*) FROM topics WHERE deleted_at IS NULL')->fetchColumn();
    if ($count > 0 || $topics === 0) {
        return;
    }

    $indexed = $search->reindexAll();
    fwrite(STDOUT, "Search index auto-built for {$indexed} topic(s).\n");
}

function build_search_repository(Database $db): SearchRepository
{
    $topicTags = new TopicTags();

    return new SearchRepository($db, new PostFormatter(), new TagRepository($db, $topicTags));
}

function run_totp(array $argv): void
{
    $action = $argv[2] ?? '';
    if ($action === 'reset') {
        run_totp_reset($argv);

        return;
    }

    fwrite(STDERR, "Usage: php bin/latch totp reset <username> --confirm\n");
    exit(1);
}

function run_totp_reset(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    if (!isset($opts['confirm'])) {
        fwrite(STDERR, "Refusing to reset 2FA without --confirm\n");
        exit(1);
    }

    $username = trim((string) ($argv[3] ?? ''));
    if ($username === '') {
        fwrite(STDERR, "Usage: php bin/latch totp reset <username> --confirm\n");
        exit(1);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $users = new UserRepository($db);
    $user = $users->findByUsername($username);
    if ($user === null) {
        fwrite(STDERR, "User not found: {$username}\n");
        exit(1);
    }

    $userId = (int) $user['id'];
    if (($user['totp_enabled_at'] ?? null) === null && ($user['totp_secret_enc'] ?? null) === null) {
        fwrite(STDOUT, "User {$username} does not have 2FA enabled — nothing to reset.\n");
        exit(0);
    }

    $users->disableTotp($userId);
    (new RecoveryCodeRepository($db))->deleteForUser($userId);

    fwrite(STDOUT, "Reset 2FA for {$username} (user #{$userId}).\n");
    fwrite(STDOUT, "They must sign in and complete 2FA setup again if their role requires it.\n");
}

/**
 * Interactive local.php walkthrough — secrets never go through the web UI.
 *
 * Usage:
 *   php bin/latch configure
 *   php bin/latch configure --show
 *   php bin/latch configure --section=turnstile
 *   php bin/latch configure --section=site,mail
 */
function run_configure(array $argv): void
{
    $opts = parse_cli_options($argv);
    $configDir = LATCH_ROOT . '/config';
    $localPath = $configDir . '/local.php';

    if (!is_file($localPath)) {
        fwrite(STDERR, "config/local.php not found. Run install first (or sudo latch-setup on RPM).\n");
        exit(1);
    }

    if (!is_writable($localPath) && !is_writable(dirname($localPath))) {
        fwrite(STDERR, "Cannot write {$localPath} — run as a user that can edit it (RPM: sudo latch configure).\n");
        exit(1);
    }

    $local = require $localPath;
    if (!is_array($local)) {
        fwrite(STDERR, "config/local.php must return an array.\n");
        exit(1);
    }

    if (isset($opts['show'])) {
        configure_show_status($local);
        exit(0);
    }

    $sections = configure_parse_sections((string) ($opts['section'] ?? 'all'));

    if (!function_exists('stream_isatty') || !stream_isatty(STDIN)) {
        fwrite(STDERR, "configure requires an interactive terminal (or use --show).\n");
        exit(1);
    }

    fwrite(STDOUT, "Latch config walkthrough — edits {$localPath}\n");
    fwrite(STDOUT, "Secrets are never printed after save. Leave blank to keep the current value.\n\n");

    $changed = false;
    foreach ($sections as $section) {
        $changed = configure_section($local, $section) || $changed;
    }

    if (!$changed) {
        fwrite(STDOUT, "No changes.\n");
        exit(0);
    }

    write_local_config($localPath, $local);
    @chmod($localPath, 0640);
    fwrite(STDOUT, "\nWrote {$localPath} (mode preferably 640; web user must read it).\n");
    fwrite(STDOUT, "Run: php bin/latch doctor\n");
}

/**
 * @return list<string>
 */
function configure_parse_sections(string $raw): array
{
    $raw = strtolower(trim($raw));
    if ($raw === '' || $raw === 'all') {
        return ['site', 'security', 'turnstile', 'staff', 'oidc', 'mail', 'plugins'];
    }

    $allowed = ['site', 'security', 'turnstile', 'staff', 'oidc', 'mail', 'plugins'];
    $parts = array_values(array_filter(array_map('trim', explode(',', $raw))));
    $out = [];
    foreach ($parts as $part) {
        if (!in_array($part, $allowed, true)) {
            fwrite(STDERR, "Unknown section '{$part}'. Allowed: " . implode(', ', $allowed) . "\n");
            exit(1);
        }
        $out[] = $part;
    }

    return $out;
}

/**
 * @param array<string, mixed> $local
 */
function configure_show_status(array $local): void
{
    $sec = is_array($local['security'] ?? null) ? $local['security'] : [];
    $site = is_array($local['site'] ?? null) ? $local['site'] : [];
    $oidc = is_array($local['oidc'] ?? null) ? $local['oidc'] : [];
    $mail = is_array($local['mail'] ?? null) ? $local['mail'] : [];
    $plugins = is_array($local['plugins'] ?? null) ? $local['plugins'] : [];

    $mask = static function (mixed $value): string {
        $s = is_string($value) ? trim($value) : '';
        if ($s === '') {
            return '(empty)';
        }

        return 'set (' . strlen($s) . ' chars)';
    };

    fwrite(STDOUT, "config/local.php (masked)\n");
    fwrite(STDOUT, '  site.url                 ' . (string) ($site['url'] ?? '(empty)') . "\n");
    fwrite(STDOUT, '  site.name                ' . (string) ($site['name'] ?? '(empty)') . "\n");
    fwrite(STDOUT, '  security.encryption_key  ' . $mask($sec['encryption_key'] ?? '') . "\n");
    fwrite(STDOUT, '  security.turnstile_site  ' . $mask($sec['turnstile_site_key'] ?? '') . "\n");
    fwrite(STDOUT, '  security.turnstile_secret ' . $mask($sec['turnstile_secret_key'] ?? '') . "\n");
    fwrite(STDOUT, '  security.trust_cloudflare ' . configure_bool_label($sec['trust_cloudflare'] ?? true) . "\n");
    fwrite(STDOUT, '  staff fingerprint        ' . configure_bool_label($sec['staff_session_fingerprint'] ?? true) . "\n");
    fwrite(STDOUT, '  staff idle minutes       ' . (string) ($sec['staff_idle_timeout_minutes'] ?? 30) . "\n");
    fwrite(STDOUT, '  staff login alerts       ' . configure_bool_label($sec['staff_login_alerts'] ?? true) . "\n");
    $g = is_array($oidc['google'] ?? null) ? $oidc['google'] : [];
    $h = is_array($oidc['github'] ?? null) ? $oidc['github'] : [];
    fwrite(STDOUT, '  oidc.google              ' . $mask($g['client_id'] ?? '') . ' / secret ' . $mask($g['client_secret'] ?? '') . "\n");
    fwrite(STDOUT, '  oidc.github              ' . $mask($h['client_id'] ?? '') . ' / secret ' . $mask($h['client_secret'] ?? '') . "\n");
    fwrite(STDOUT, '  mail.transport           ' . (string) ($mail['transport'] ?? 'msmtp') . "\n");
    fwrite(STDOUT, '  mail.from_email          ' . (string) ($mail['from_email'] ?? '(empty)') . "\n");
    $iu = is_array($plugins['image_upload'] ?? null) ? $plugins['image_upload'] : [];
    $sb = is_array($plugins['spam_bridge'] ?? null) ? $plugins['spam_bridge'] : [];
    $sn = is_array($plugins['slack_notify'] ?? null) ? $plugins['slack_notify'] : [];
    fwrite(STDOUT, '  plugins.image_upload     account ' . $mask($iu['account_id'] ?? '') . ', key ' . $mask($iu['access_key_id'] ?? '') . "\n");
    fwrite(STDOUT, '  plugins.spam_bridge      akismet ' . $mask($sb['akismet_api_key'] ?? '') . "\n");
    fwrite(STDOUT, '  plugins.slack_notify     webhook ' . $mask($sn['webhook_url'] ?? '') . "\n");
}

function configure_bool_label(mixed $value): string
{
    if ($value === false || $value === 0 || $value === '0' || $value === 'false') {
        return 'false';
    }

    return 'true';
}

/**
 * @param array<string, mixed> $local
 */
function configure_section(array &$local, string $section): bool
{
    return match ($section) {
        'site' => configure_section_site($local),
        'security' => configure_section_security($local),
        'turnstile' => configure_section_turnstile($local),
        'staff' => configure_section_staff($local),
        'oidc' => configure_section_oidc($local),
        'mail' => configure_section_mail($local),
        'plugins' => configure_section_plugins($local),
        default => false,
    };
}

/**
 * @param array<string, mixed> $local
 */
function configure_section_site(array &$local): bool
{
    fwrite(STDOUT, "── Site ──\n");
    if (!isset($local['site']) || !is_array($local['site'])) {
        $local['site'] = [];
    }
    $changed = false;
    $url = (string) ($local['site']['url'] ?? 'http://localhost');
    $name = (string) ($local['site']['name'] ?? 'Latch');
    $newUrl = prompt('Public site URL (https://…)', $url);
    $newName = prompt('Site name', $name);
    if ($newUrl !== $url) {
        $local['site']['url'] = rtrim($newUrl, '/');
        $changed = true;
    }
    if ($newName !== $name) {
        $local['site']['name'] = $newName;
        $changed = true;
    }

    return $changed;
}

/**
 * @param array<string, mixed> $local
 */
function configure_section_security(array &$local): bool
{
    fwrite(STDOUT, "── Security (encryption key) ──\n");
    if (!isset($local['security']) || !is_array($local['security'])) {
        $local['security'] = [];
    }
    $key = trim((string) ($local['security']['encryption_key'] ?? ''));
    if ($key === '' || $key === 'REPLACE_WITH_BASE64_32_BYTE_KEY') {
        $gen = strtolower(prompt('Generate security.encryption_key now? (required for admin 2FA)', 'y'));
        if (in_array($gen, ['y', 'yes'], true)) {
            $local['security']['encryption_key'] = generate_encryption_key_b64();
            fwrite(STDOUT, "  Generated encryption_key (not shown).\n");

            return true;
        }

        return false;
    }

    fwrite(STDOUT, "  encryption_key: already set (leave alone; use security-bootstrap only if re-wrapping TOTP).\n");
    $trust = configure_prompt_bool(
        'Trust Cloudflare client IPs (CF-Connecting-IP when CF-Ray present)?',
        (bool) ($local['security']['trust_cloudflare'] ?? true),
    );
    if ($trust !== (bool) ($local['security']['trust_cloudflare'] ?? true)) {
        $local['security']['trust_cloudflare'] = $trust;

        return true;
    }

    return false;
}

/**
 * @param array<string, mixed> $local
 */
function configure_section_turnstile(array &$local): bool
{
    fwrite(STDOUT, "── Cloudflare Turnstile ──\n");
    fwrite(STDOUT, "  Docs: https://developers.cloudflare.com/turnstile/get-started/\n");
    fwrite(STDOUT, "  Latch guide: docs/CLOUDFLARE.md — same keys for register + login.\n");
    if (!isset($local['security']) || !is_array($local['security'])) {
        $local['security'] = [];
    }
    $changed = false;
    $site = configure_prompt_secret('Turnstile site key', (string) ($local['security']['turnstile_site_key'] ?? ''));
    if ($site !== null) {
        $local['security']['turnstile_site_key'] = $site;
        $changed = true;
    }
    $secret = configure_prompt_secret('Turnstile secret key', (string) ($local['security']['turnstile_secret_key'] ?? ''));
    if ($secret !== null) {
        $local['security']['turnstile_secret_key'] = $secret;
        $changed = true;
    }
    if ($changed) {
        fwrite(STDOUT, "  Enable widgets in Admin → Settings (or Security mode → High).\n");
    }

    return $changed;
}

/**
 * @param array<string, mixed> $local
 */
function configure_section_staff(array &$local): bool
{
    fwrite(STDOUT, "── Staff session hardening (admin/mod) ──\n");
    if (!isset($local['security']) || !is_array($local['security'])) {
        $local['security'] = [];
    }
    $changed = false;
    $fp = configure_prompt_bool(
        'Bind staff sessions to IP + browser fingerprint?',
        (bool) ($local['security']['staff_session_fingerprint'] ?? true),
    );
    if ($fp !== (bool) ($local['security']['staff_session_fingerprint'] ?? true)) {
        $local['security']['staff_session_fingerprint'] = $fp;
        $changed = true;
    }
    $idleDefault = (string) ($local['security']['staff_idle_timeout_minutes'] ?? 30);
    $idle = prompt('Staff idle timeout minutes (0 = off)', $idleDefault);
    if ($idle !== $idleDefault && ctype_digit($idle)) {
        $local['security']['staff_idle_timeout_minutes'] = (int) $idle;
        $changed = true;
    }
    $alerts = configure_prompt_bool(
        'Email staff on new device login?',
        (bool) ($local['security']['staff_login_alerts'] ?? true),
    );
    if ($alerts !== (bool) ($local['security']['staff_login_alerts'] ?? true)) {
        $local['security']['staff_login_alerts'] = $alerts;
        $changed = true;
    }

    return $changed;
}

/**
 * @param array<string, mixed> $local
 */
function configure_section_oidc(array &$local): bool
{
    fwrite(STDOUT, "── Social login (OIDC) — optional ──\n");
    $skip = strtolower(prompt('Configure Google/GitHub OAuth app credentials?', 'n'));
    if (!in_array($skip, ['y', 'yes'], true)) {
        return false;
    }
    if (!isset($local['oidc']) || !is_array($local['oidc'])) {
        $local['oidc'] = [];
    }
    $changed = false;
    foreach (['google', 'github'] as $provider) {
        if (!isset($local['oidc'][$provider]) || !is_array($local['oidc'][$provider])) {
            $local['oidc'][$provider] = ['client_id' => '', 'client_secret' => ''];
        }
        fwrite(STDOUT, "  {$provider}:\n");
        $id = configure_prompt_secret('  client_id', (string) ($local['oidc'][$provider]['client_id'] ?? ''));
        if ($id !== null) {
            $local['oidc'][$provider]['client_id'] = $id;
            $changed = true;
        }
        $secret = configure_prompt_secret('  client_secret', (string) ($local['oidc'][$provider]['client_secret'] ?? ''));
        if ($secret !== null) {
            $local['oidc'][$provider]['client_secret'] = $secret;
            $changed = true;
        }
    }
    if ($changed) {
        fwrite(STDOUT, "  Enable providers in Admin → Settings after keys are set.\n");
    }

    return $changed;
}

/**
 * @param array<string, mixed> $local
 */
function configure_section_mail(array &$local): bool
{
    fwrite(STDOUT, "── Outbound mail ──\n");
    if (!isset($local['mail']) || !is_array($local['mail'])) {
        $local['mail'] = [];
    }
    $changed = false;
    $transport = (string) ($local['mail']['transport'] ?? 'msmtp');
    $newT = prompt('Transport (msmtp|mail)', $transport);
    if (in_array($newT, ['msmtp', 'mail'], true) && $newT !== $transport) {
        $local['mail']['transport'] = $newT;
        $changed = true;
    }
    $from = (string) ($local['mail']['from_email'] ?? 'noreply@localhost');
    $newFrom = prompt('From email', $from);
    if ($newFrom !== $from) {
        $local['mail']['from_email'] = $newFrom;
        $changed = true;
    }
    $fromName = (string) ($local['mail']['from_name'] ?? 'Latch');
    $newName = prompt('From name', $fromName);
    if ($newName !== $fromName) {
        $local['mail']['from_name'] = $newName;
        $changed = true;
    }
    if (($local['mail']['transport'] ?? 'msmtp') === 'msmtp') {
        $cfg = (string) ($local['mail']['msmtp_config'] ?? '');
        $newCfg = prompt('msmtp config path (optional)', $cfg !== '' ? $cfg : '/etc/msmtprc');
        if ($newCfg !== $cfg && $newCfg !== '') {
            $local['mail']['msmtp_config'] = $newCfg;
            $changed = true;
        }
    }
    if ($changed) {
        fwrite(STDOUT, "  Test with: php bin/latch test-mail --to=you@example.com\n");
    }

    return $changed;
}

/**
 * @param array<string, mixed> $local
 */
function configure_section_plugins(array &$local): bool
{
    fwrite(STDOUT, "── Plugin secrets (optional) ──\n");
    $skip = strtolower(prompt('Configure image-upload / spam-bridge / slack secrets?', 'n'));
    if (!in_array($skip, ['y', 'yes'], true)) {
        return false;
    }
    if (!isset($local['plugins']) || !is_array($local['plugins'])) {
        $local['plugins'] = [];
    }
    $changed = false;

    fwrite(STDOUT, "  image-upload (R2):\n");
    if (!isset($local['plugins']['image_upload']) || !is_array($local['plugins']['image_upload'])) {
        $local['plugins']['image_upload'] = [];
    }
    foreach (['account_id', 'access_key_id', 'secret_access_key', 'bucket', 'public_host'] as $key) {
        $cur = (string) ($local['plugins']['image_upload'][$key] ?? '');
        $isSecret = str_contains($key, 'secret') || str_contains($key, 'key_id') || $key === 'access_key_id';
        if ($isSecret || $key === 'account_id' || $key === 'secret_access_key') {
            $val = configure_prompt_secret("  {$key}", $cur);
            if ($val !== null) {
                $local['plugins']['image_upload'][$key] = $val;
                $changed = true;
            }
        } else {
            $val = prompt("  {$key}", $cur !== '' ? $cur : ($key === 'bucket' ? 'latch-forum-images' : ''));
            if ($val !== $cur) {
                $local['plugins']['image_upload'][$key] = $val;
                $changed = true;
            }
        }
    }

    fwrite(STDOUT, "  spam-bridge:\n");
    if (!isset($local['plugins']['spam_bridge']) || !is_array($local['plugins']['spam_bridge'])) {
        $local['plugins']['spam_bridge'] = [];
    }
    $ak = configure_prompt_secret('  akismet_api_key', (string) ($local['plugins']['spam_bridge']['akismet_api_key'] ?? ''));
    if ($ak !== null) {
        $local['plugins']['spam_bridge']['akismet_api_key'] = $ak;
        $changed = true;
    }

    fwrite(STDOUT, "  slack-notify:\n");
    if (!isset($local['plugins']['slack_notify']) || !is_array($local['plugins']['slack_notify'])) {
        $local['plugins']['slack_notify'] = [];
    }
    $wh = configure_prompt_secret('  webhook_url', (string) ($local['plugins']['slack_notify']['webhook_url'] ?? ''));
    if ($wh !== null) {
        $local['plugins']['slack_notify']['webhook_url'] = $wh;
        $changed = true;
    }

    return $changed;
}

/**
 * Prompt for a secret. Returns null if user leaves blank (keep current).
 * Empty string can be forced with a single "-" to clear.
 */
function configure_prompt_secret(string $label, string $current): ?string
{
    $hint = $current !== '' ? 'set, ' . strlen($current) . ' chars' : 'empty';
    if (!function_exists('readline') || !stream_isatty(STDIN)) {
        return null;
    }

    $value = readline("{$label} [{$hint}; blank=keep, -=clear]: ");
    if ($value === false) {
        return null;
    }
    $value = trim($value);
    if ($value === '') {
        return null;
    }
    if ($value === '-') {
        return '';
    }

    return $value;
}

function configure_prompt_bool(string $label, bool $default): bool
{
    $def = $default ? 'y' : 'n';
    $answer = strtolower(prompt($label . ' (y/n)', $def));

    return in_array($answer, ['y', 'yes', '1', 'true'], true);
}

function run_security_bootstrap(): void
{
    require_pdo();

    $configDir = LATCH_ROOT . '/config';
    $localPath = $configDir . '/local.php';
    if (!is_file($localPath)) {
        fwrite(STDERR, "config/local.php not found.\n");
        exit(1);
    }

    $local = require $localPath;
    if (!is_array($local)) {
        fwrite(STDERR, "config/local.php must return an array.\n");
        exit(1);
    }

    $config = new Config($configDir);
    if (!encryption_key_missing($config)) {
        fwrite(STDOUT, "security.encryption_key already set — nothing to do.\n");
        exit(0);
    }
    $db = latch_cli_database($config);
    $users = new UserRepository($db);
    $derivedCipher = new SecretCipher($config);

    $plaintextSecrets = [];
    foreach ($users->listTotpEnabled() as $row) {
        $plain = $derivedCipher->decrypt((string) $row['totp_secret_enc']);
        if ($plain === null) {
            fwrite(STDERR, "Could not decrypt TOTP secret for user #{$row['id']}. Aborting.\n");
            exit(1);
        }
        $plaintextSecrets[(int) $row['id']] = $plain;
    }

    $newKey = base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES));
    $rawKey = base64_decode($newKey, true);
    if ($rawKey === false || strlen($rawKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
        fwrite(STDERR, "Failed to generate encryption key.\n");
        exit(1);
    }

    $cipher = new SecretCipher($config, $rawKey);
    foreach ($plaintextSecrets as $userId => $plain) {
        $users->updateTotpSecretEnc($userId, $cipher->encrypt($plain));
    }

    $local['security']['encryption_key'] = $newKey;
    write_local_config($localPath, $local);
    fwrite(STDOUT, "Wrote security.encryption_key to config/local.php\n");

    if ($plaintextSecrets !== []) {
        fwrite(STDOUT, 'Re-wrapped TOTP secrets for ' . count($plaintextSecrets) . " user(s).\n");
    }

    fwrite(STDOUT, "Security bootstrap complete.\n");
}

function run_test_mail(array $argv): void
{
    require_pdo();

    $opts = parse_cli_options($argv);
    $to = trim((string) ($opts['to'] ?? ''));

    if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
        fwrite(STDERR, "Usage: php bin/latch test-mail --to=email@example.com\n");
        exit(1);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $settings = new SettingRepository($db);
    $mail = new Mail($config, $settings);

    $status = $mail->status();
    fwrite(STDOUT, "Mail status:\n");
    foreach ($status as $key => $value) {
        if (is_bool($value)) {
            $value = $value ? 'yes' : 'no';
        }
        fwrite(STDOUT, "  {$key}: {$value}\n");
    }

    if (!$mail->isConfigured()) {
        fwrite(STDERR, "\nMail is not configured. See source/docs/EMAIL.md or deploy/msmtp.conf.example\n");
        exit(1);
    }

    $siteName = (string) $config->get('site.name', 'Latch');
    $subject = "{$siteName} test email";
    $body = "This is a test message from Latch sent at " . gmdate('c') . " UTC.\n\n"
        . "If you received this, outbound mail is working.";

    fwrite(STDOUT, "\nSending test email to {$to}...\n");

    if (!$mail->send($to, $subject, $body)) {
        fwrite(STDERR, "Send failed: " . ($mail->lastError() ?? 'unknown error') . "\n");
        exit(1);
    }

    fwrite(STDOUT, "Test email sent successfully.\n");
}

function run_mail(array $argv): void
{
    require_pdo();

    $sub = $argv[2] ?? '';
    if ($sub !== 'process') {
        fwrite(STDERR, "Usage: php bin/latch mail process\n");
        exit(1);
    }

    $config = new Config(LATCH_ROOT . '/config');
    $storagePath = (string) $config->get('paths.storage');
    if (SiteLock::isLocked($storagePath)) {
        fwrite(STDOUT, "Skipped: site lock is enabled (php bin/latch lock off).\n");
        exit(0);
    }

    $db = latch_cli_database($config);
    $mailQueue = build_mail_queue_service($db);
    $pending = $mailQueue->pendingCount();

    if ($pending === 0) {
        fwrite(STDOUT, "mail_queue: no pending messages\n");
        exit(0);
    }

    $stats = $mailQueue->processBatch();
    fwrite(STDOUT, sprintf(
        "mail_queue: pending=%d sent=%d failed=%d remaining=%d\n",
        $pending,
        $stats['sent'],
        $stats['failed'],
        $mailQueue->pendingCount(),
    ));
}

function run_api_client(array $argv): void
{
    require_pdo();

    $sub = $argv[2] ?? 'help';
    $opts = parse_cli_options($argv);
    $config = new Config(LATCH_ROOT . '/config');
    $db = latch_cli_database($config);
    $clients = new OAuthClientRepository($db);
    $users = new UserRepository($db);

    match ($sub) {
        'create' => api_client_create($clients, $users, $opts),
        'add-redirect' => api_client_add_redirect($clients, $opts),
        'list' => api_client_list($clients),
        'revoke' => api_client_revoke($clients, $opts),
        'help', '--help', '-h' => api_client_help(),
        default => api_client_unknown($sub),
    };
}

function api_client_help(): void
{
    fwrite(STDOUT, <<<HELP
Latch OAuth API clients

  php bin/latch api-client create --name="My App" [--redirect=URL] [--scopes=read,messages:read,messages:write] [--public] [--rate-limit=60]
  php bin/latch api-client add-redirect --client-id=latch_... --redirect=URL
  php bin/latch api-client list
  php bin/latch api-client revoke --client-id=latch_...

HELP);
}

function api_client_unknown(string $sub): void
{
    fwrite(STDERR, "Unknown api-client subcommand: {$sub}\n");
    api_client_help();
    exit(1);
}

/**
 * @param array<string, string> $opts
 */
function api_client_create(OAuthClientRepository $clients, UserRepository $users, array $opts): void
{
    $name = trim((string) ($opts['name'] ?? ''));
    if ($name === '') {
        fwrite(STDERR, "--name is required.\n");
        exit(1);
    }

    $isPublic = isset($opts['public']);
    $redirects = [];
    foreach ($opts as $key => $value) {
        if (str_starts_with($key, 'redirect') && $value !== '') {
            $redirects[] = (string) $value;
        }
    }

    $creator = null;
    $creatorName = trim((string) ($opts['user'] ?? ''));
    if ($creatorName !== '') {
        $creator = $users->findByUsername($creatorName);
    } else {
        foreach ($users->all() as $user) {
            if (($user['role'] ?? '') === 'admin') {
                $creator = $user;
                break;
            }
        }
    }

    if ($creator === null || ($creator['role'] ?? '') !== 'admin') {
        fwrite(STDERR, "Admin user required (--user=admin).\n");
        exit(1);
    }

    $rateLimit = max(10, min(600, (int) ($opts['rate-limit'] ?? 60)));

    $scopeInput = trim((string) ($opts['scopes'] ?? OAuthScopes::READ));
    $scopes = OAuthScopes::normalize(
        $scopeInput !== ''
            ? array_map('trim', explode(',', $scopeInput))
            : [OAuthScopes::READ],
    );

    try {
        $result = $clients->create(
            $name,
            $redirects,
            $scopes,
            !$isPublic,
            (int) $creator['id'],
            $rateLimit,
        );
    } catch (\Throwable $e) {
        fwrite(STDERR, $e->getMessage() . "\n");
        exit(1);
    }

    $client = $result['client'];
    fwrite(STDOUT, "OAuth client created.\n");
    fwrite(STDOUT, "  name:       {$client['name']}\n");
    fwrite(STDOUT, "  client_id:  {$client['client_id']}\n");
    if ($result['client_secret'] !== null) {
        fwrite(STDOUT, "  secret:     {$result['client_secret']}\n");
        fwrite(STDOUT, "  (store the secret now — it cannot be shown again)\n");
    } else {
        fwrite(STDOUT, "  type:       public (PKCE)\n");
    }
    fwrite(STDOUT, '  scopes:     ' . OAuthScopes::toString($scopes) . "\n");
    fwrite(STDOUT, "  rate limit: {$rateLimit}/min\n");
}

/**
 * @param array<string, string> $opts
 */
function api_client_add_redirect(OAuthClientRepository $clients, array $opts): void
{
    $clientId = trim((string) ($opts['client-id'] ?? ''));
    $redirect = trim((string) ($opts['redirect'] ?? ''));
    if ($clientId === '' || $redirect === '') {
        fwrite(STDERR, "--client-id and --redirect are required.\n");
        exit(1);
    }

    if (!$clients->addRedirectUri($clientId, $redirect)) {
        fwrite(STDERR, "Failed to add redirect URI (unknown client or DB error).\n");
        exit(1);
    }

    fwrite(STDOUT, "Redirect URI added for {$clientId}:\n  {$redirect}\n");
}

function api_client_list(OAuthClientRepository $clients): void
{
    $rows = $clients->listAll();
    if ($rows === []) {
        fwrite(STDOUT, "No OAuth clients.\n");

        return;
    }

    foreach ($rows as $row) {
        $status = ($row['revoked_at'] ?? null) !== null ? 'revoked' : 'active';
        $type = (int) ($row['is_confidential'] ?? 1) === 1 ? 'confidential' : 'public';
        fwrite(STDOUT, sprintf(
            "%s  %s  %s  by %s  %s\n",
            $row['client_id'],
            $status,
            $type,
            $row['created_by_username'] ?? '?',
            $row['name'],
        ));
    }
}

/**
 * @param array<string, string> $opts
 */
function run_test_api_messages(array $argv): void
{
    $opts = parse_cli_options($argv);
    $sub = $argv[2] ?? '';
    if ($sub === '' || str_starts_with($sub, '--')) {
        $sub = 'all';
    }

    $configPath = (string) ($opts['config'] ?? LATCH_ROOT . '/tests/api/config.local.php');
    $examplePath = LATCH_ROOT . '/tests/api/config.example.php';

    if (!is_file($configPath)) {
        fwrite(STDERR, "API test config not found: {$configPath}\n");
        fwrite(STDERR, "Copy {$examplePath} to tests/api/config.local.php\n");
        fwrite(STDERR, "Create a production OAuth client (on the server):\n");
        fwrite(STDERR, "  sudo -u apache php bin/latch api-client create --name=\"Local API Harness\" \\\n");
        fwrite(STDERR, "    --redirect=https://forum.example.com/oauth/cli-callback \\\n");
        fwrite(STDERR, "    --scopes=read,messages:read,messages:write\n");
        exit(1);
    }

    $config = require $configPath;
    if (!is_array($config)) {
        fwrite(STDERR, "Invalid config: must return an array.\n");
        exit(1);
    }

    if (isset($opts['url']) && $opts['url'] !== '') {
        $config['base_url'] = $opts['url'];
    }

    require LATCH_ROOT . '/tests/api/MessagesApiHarness.php';

    $harness = new MessagesApiHarness($config);
    $base = rtrim((string) ($config['base_url'] ?? ''), '/');
    fwrite(STDOUT, "Latch messages API harness → {$base}\n\n");

    if (in_array($sub, ['help', '--help', '-h'], true)) {
        exit(messages_api_harness_help());
    }

    if (!in_array($sub, ['authorize', 'auth', 'run', 'all'], true)) {
        fwrite(STDERR, "Unknown subcommand: {$sub}\n");
        exit(messages_api_harness_help());
    }

    $exit = match ($sub) {
        'authorize', 'auth' => $harness->authorizeInteractive(),
        'run' => $harness->run(),
        'all' => $harness->runAll(),
    };

    exit($exit);
}

function messages_api_harness_help(): int
{
    fwrite(STDOUT, <<<HELP
Messages API harness (user-delegated OAuth + PKCE)

  php bin/latch test-api-messages authorize   Open browser flow; saves user token
  php bin/latch test-api-messages run         Run tests with cached token
  php bin/latch test-api-messages             Authorize if needed, then run

Config: tests/api/config.local.php (see config.example.php)
Token cache: tests/api/user-token.local.json (gitignored)

HELP);

    return 0;
}

function run_test_api(array $argv): void
{
    $opts = parse_cli_options($argv);
    $configPath = (string) ($opts['config'] ?? LATCH_ROOT . '/tests/api/config.local.php');
    $examplePath = LATCH_ROOT . '/tests/api/config.example.php';

    if (!is_file($configPath)) {
        fwrite(STDERR, "API test config not found: {$configPath}\n");
        fwrite(STDERR, "Copy {$examplePath} to tests/api/config.local.php and add OAuth client credentials.\n");
        exit(1);
    }

    $config = require $configPath;
    if (!is_array($config)) {
        fwrite(STDERR, "Invalid config: must return an array.\n");
        exit(1);
    }

    if (isset($opts['url']) && $opts['url'] !== '') {
        $config['base_url'] = $opts['url'];
    }

    require LATCH_ROOT . '/tests/api/ApiHarness.php';

    fwrite(STDOUT, "Latch API harness → " . rtrim((string) ($config['base_url'] ?? ''), '/') . "\n\n");

    $harness = new ApiHarness($config);
    exit($harness->run());
}

function api_client_revoke(OAuthClientRepository $clients, array $opts): void
{
    $clientId = trim((string) ($opts['client-id'] ?? ''));
    if ($clientId === '') {
        fwrite(STDERR, "--client-id is required.\n");
        exit(1);
    }

    if (!$clients->revoke($clientId)) {
        fwrite(STDERR, "Client not found or already revoked.\n");
        exit(1);
    }

    fwrite(STDOUT, "Revoked {$clientId}\n");
}

function generate_encryption_key_b64(): string
{
    return base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES));
}

function encryption_key_missing(Config $config): bool
{
    return !(new SecretCipher($config))->hasConfiguredKey();
}

function write_local_config(string $path, array $config): void
{
    $export = var_export($config, true);
    $php = "<?php\n\ndeclare(strict_types=1);\n\nreturn {$export};\n";

    if (file_put_contents($path, $php) === false) {
        fwrite(STDERR, "Could not write {$path}\n");
        exit(1);
    }
}