<?php
/**
 * SocietyPress One-Click Installer
 *
 * A single-file installer that takes a society administrator from empty hosting
 * to a fully running SocietyPress installation. Upload this file, visit it in
 * a browser, fill out one form, and SocietyPress + WordPress are installed,
 * configured, and ready to go.
 *
 * WHY: Our target users are non-technical society volunteers. Asking them to
 * install WordPress, find and upload a plugin, activate a theme, and configure
 * settings is too many steps. This installer reduces it to: upload one file,
 * fill out one form, click one button.
 *
 * SECURITY: This file MUST self-delete after successful installation. An
 * installer script left on a production server is a critical vulnerability.
 *
 * @package    SocietyPress
 * @license    GPL-2.0-or-later
 * @version    1.0.0
 */

// ============================================================================
// EARLY PHP VERSION GUARD
// ============================================================================
// WHY: SocietyPress requires PHP 8.0+. The function bodies later in this file
// use PHP 8.0+ syntax (match, nullsafe, named args), which PHP 7.x rejects at
// parse time — meaning a truly old host produces a generic 500 with no useful
// message. This guard uses only PHP 5.x-compatible syntax so it can fire on
// any host that still successfully parses the surrounding code (e.g., partial
// parsers, opcache edge cases, or PHP variants with looser parsing). For hosts
// where the parser hard-rejects the whole file before reaching this line, the
// guard is harmless dead weight — but it costs nothing to leave in.
if ( PHP_VERSION_ID < 80000 ) {
    header( 'Content-Type: text/html; charset=utf-8' );
    echo '<!doctype html><meta charset="utf-8"><title>PHP Version Too Old</title>'
        . '<div style="font-family:system-ui,sans-serif;max-width:600px;margin:60px auto;padding:24px;border:1px solid #e5e7eb;border-radius:8px">'
        . '<h1 style="margin-top:0;color:#991B1B">PHP version too old</h1>'
        . '<p>SocietyPress requires PHP 8.0 or newer. This server is running PHP '
        . htmlspecialchars( PHP_VERSION, ENT_QUOTES, 'UTF-8' ) . '.</p>'
        . '<p>Most cPanel hosts let you change the PHP version under the "MultiPHP Manager" or "PHP Selector" tool. After upgrading to PHP 8.0+, reload this page.</p>'
        . '</div>';
    exit( 1 );
}

// ============================================================================
// SAFETY CHECKS
// ============================================================================

// Prevent direct CLI execution — this is a web-only tool
if ( php_sapi_name() === 'cli' ) {
    echo "This installer must be run from a web browser.\n";
    exit( 1 );
}

// WHY OR (not AND): Either file alone signals a partial or full WordPress
// install in this directory. A failed install can leave wp-config.php on
// disk without wp-includes/version.php (or vice versa) — using AND would
// let the installer overwrite a half-finished install, potentially
// destroying a wp-config.php with the only copy of the DB password.
if ( file_exists( __DIR__ . '/wp-config.php' ) || file_exists( __DIR__ . '/wp-includes/version.php' ) ) {
    // sp_installer_die() and the other render helpers are defined lower in this
    // file (PHP hoists named functions, so calling them up here is fine).
    sp_installer_die(
        'WordPress Is Already Installed',
        'A WordPress installation already exists in this directory (or a previous install left files behind). If you need to reinstall, '
        . 'remove the existing wp-config.php and wp-includes/ directory first. If SocietyPress is already running, you can safely '
        . 'delete this sp-installer.php file.'
    );
}

// ============================================================================
// CONFIGURATION
// ============================================================================

// Where to download WordPress and SocietyPress from
define( 'SP_INSTALLER_WP_URL',     'https://wordpress.org/latest.zip' );
define( 'SP_INSTALLER_BUNDLE_URL', 'https://getsocietypress.org/downloads/societypress-latest.zip' );
define( 'SP_INSTALLER_GITHUB_REPO', 'SocietyPressFoundation/SocietyPress' );
define( 'SP_INSTALLER_SALT_URL',    'https://api.wordpress.org/secret-key/1.1/salt/' );

// Demo mode: if a config file exists outside the web root, load it.
// WHY: On the demo site, DB credentials are pre-configured so visitors don't need
// to know them. The config file lives outside public_html so it's never web-accessible.
// When this file exists, the installer hides the DB fields and shows "Pre-configured."
// On a real install (no config file), the full DB form is shown.
//
// SECURITY: Earlier this loop walked $_SERVER['DOCUMENT_ROOT'], $_SERVER['HOME'],
// and $_SERVER['USER'] to build candidate paths. Those values can be influenced
// by request headers (Host:) or by misconfigured shared-hosting layouts, and the
// resolved path then went straight into require_once — i.e., attacker-influenced
// arbitrary file include. We now require the demo config to be at one fixed
// absolute path that has nothing to do with $_SERVER. If the file isn't there,
// demo mode is simply off.
// WHY an environment variable, not a hardcoded path: a literal absolute path
// would leak the hosting account's username and home-directory layout into the
// public repository. The demo server sets SP_DEMO_CONFIG (via .htaccess or
// php.ini) to the absolute path of the out-of-webroot config file. On any real
// install the variable is simply unset, so demo mode stays off.
$sp_demo_config = (string) getenv( 'SP_DEMO_CONFIG' );
if ( $sp_demo_config === '' || ! file_exists( $sp_demo_config ) ) {
    $sp_demo_config = '';
}
define( 'SP_INSTALLER_DEMO_MODE', $sp_demo_config !== '' );
if ( SP_INSTALLER_DEMO_MODE ) {
    require_once $sp_demo_config;
}

// Minimum requirements
define( 'SP_INSTALLER_MIN_PHP',     '8.0.0' );
define( 'SP_INSTALLER_MIN_MYSQL',   '5.7.0' );


// ============================================================================
// MAIN ROUTING
// ============================================================================

// WHY suppress display_errors here: shared hosts default php.ini often has
// display_errors=On, which would inject absolute server paths into the
// rendered HTML on a stray notice/warning. error_log still receives them.
@ini_set( 'display_errors', '0' );
@ini_set( 'log_errors',     '1' );
error_reporting( E_ALL );

// WHY harden session before start: PHP defaults vary by host. Force the
// session cookie to HttpOnly and use strict-mode so an attacker can't
// pre-set a session ID via shared-host sibling processes.
@ini_set( 'session.use_strict_mode', '1' );
@ini_set( 'session.cookie_httponly', '1' );
// WHY cookie_secure when on HTTPS: prevents the install-session cookie
// (which carries the CSRF nonce) from leaking over plaintext if the user
// hits the http:// URL or a downgrade attack forces it.
if ( ! empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) {
    @ini_set( 'session.cookie_secure', '1' );
}
// WHY SameSite=Strict: defense-in-depth against CSRF on the install POST.
// The nonce check is the primary guard, but Strict means the install-session
// cookie never accompanies cross-site requests at all.
@ini_set( 'session.cookie_samesite', 'Strict' );
session_start();

$step = $_GET['step'] ?? 'check';

// If the form was submitted, process it
if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset( $_POST['sp_install_nonce'] ) ) {
    sp_installer_process();
    exit;
}

// Otherwise show the appropriate step
switch ( $step ) {
    case 'check':
        sp_installer_check_requirements();
        break;
    case 'configure':
        sp_installer_show_form();
        break;
    case 'complete':
        sp_installer_show_complete();
        break;
    default:
        sp_installer_check_requirements();
}
exit;


// ============================================================================
// STEP 1: REQUIREMENTS CHECK
// ============================================================================

/**
 * Check that the server meets all requirements before showing the config form.
 *
 * WHY: Better to tell Harold upfront "your hosting doesn't support this" than
 * to let him fill out a form and fail halfway through the install.
 */
function sp_installer_check_requirements(): void {
    $checks = [];
    $all_pass = true;

    // PHP version
    $php_ok = version_compare( PHP_VERSION, SP_INSTALLER_MIN_PHP, '>=' );
    $checks[] = [
        'label'  => 'PHP ' . SP_INSTALLER_MIN_PHP . '+',
        'status' => $php_ok,
        'value'  => PHP_VERSION,
        'note'   => $php_ok ? '' : 'Contact your hosting provider to upgrade PHP.',
    ];
    if ( ! $php_ok ) $all_pass = false;

    // ZipArchive (needed to extract downloads)
    $zip_ok = class_exists( 'ZipArchive' );
    $checks[] = [
        'label'  => 'ZipArchive Extension',
        'status' => $zip_ok,
        'value'  => $zip_ok ? 'Available' : 'Missing',
        'note'   => $zip_ok ? '' : 'Required to extract downloaded files. Contact your host.',
    ];
    if ( ! $zip_ok ) $all_pass = false;

    // cURL or allow_url_fopen (needed to download files)
    $curl_ok = function_exists( 'curl_init' );
    $fopen_ok = (bool) ini_get( 'allow_url_fopen' );
    $dl_ok = $curl_ok || $fopen_ok;
    $dl_value = $curl_ok ? 'cURL available' : ( $fopen_ok ? 'allow_url_fopen enabled' : 'Neither available' );
    $checks[] = [
        'label'  => 'HTTP Downloads (cURL or allow_url_fopen)',
        'status' => $dl_ok,
        'value'  => $dl_value,
        'note'   => $dl_ok ? '' : 'The installer needs to download WordPress and SocietyPress. Contact your host.',
    ];
    if ( ! $dl_ok ) $all_pass = false;

    // Sodium (needed for SocietyPress encryption)
    $sodium_ok = function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' );
    $checks[] = [
        'label'  => 'Sodium Extension (for encryption)',
        'status' => $sodium_ok,
        'value'  => $sodium_ok ? 'Available' : 'Missing',
        'note'   => $sodium_ok ? '' : 'SocietyPress uses this to encrypt sensitive member data. It works without it, but encryption will be disabled.',
    ];
    // Sodium is recommended but not required — don't fail the check

    // MySQL / MariaDB via PDO or mysqli
    $db_ok = extension_loaded( 'mysqli' ) || extension_loaded( 'pdo_mysql' );
    $checks[] = [
        'label'  => 'MySQL Support (mysqli or PDO)',
        'status' => $db_ok,
        'value'  => $db_ok ? ( extension_loaded( 'mysqli' ) ? 'mysqli' : 'PDO' ) : 'Missing',
        'note'   => $db_ok ? '' : 'WordPress requires MySQL. Contact your host.',
    ];
    if ( ! $db_ok ) $all_pass = false;

    // Directory writable
    $write_ok = is_writable( __DIR__ );
    $checks[] = [
        'label'  => 'Directory Writable',
        'status' => $write_ok,
        'value'  => $write_ok ? 'Yes' : 'No',
        'note'   => $write_ok ? '' : 'The installer needs to write files to this directory. Check your hosting file permissions.',
    ];
    if ( ! $write_ok ) $all_pass = false;

    // Disk space (need ~100MB for WordPress + SocietyPress + temp files)
    $free_space = @disk_free_space( __DIR__ );
    $space_ok = ( $free_space === false ) || ( $free_space > 100 * 1024 * 1024 );
    $checks[] = [
        'label'  => 'Disk Space (100 MB+ recommended)',
        'status' => $space_ok,
        'value'  => $free_space !== false ? round( $free_space / 1024 / 1024 ) . ' MB free' : 'Unable to check',
        'note'   => $space_ok ? '' : 'You may not have enough disk space. WordPress + SocietyPress need about 100 MB.',
    ];

    sp_installer_render_page( 'Server Requirements Check', function () use ( $checks, $all_pass ) {
        ?>
        <p style="margin-bottom: 24px; color: #6B7280;">
            Before we begin, let's make sure your hosting can run SocietyPress.
        </p>

        <table style="width: 100%; border-collapse: collapse; margin-bottom: 24px;">
            <?php foreach ( $checks as $check ) : ?>
                <tr style="border-bottom: 1px solid #E5E7EB;">
                    <td style="padding: 12px 16px; font-weight: 500;">
                        <?php echo htmlspecialchars( $check['label'] , ENT_QUOTES, 'UTF-8'); ?>
                    </td>
                    <td style="padding: 12px 16px; text-align: center; width: 40px;">
                        <?php if ( $check['status'] ) : ?>
                            <span style="color: #16A34A; font-size: 20px;">&#10003;</span>
                        <?php else : ?>
                            <span style="color: #DC2626; font-size: 20px;">&#10005;</span>
                        <?php endif; ?>
                    </td>
                    <td style="padding: 12px 16px; color: #6B7280;">
                        <?php echo htmlspecialchars( $check['value'] , ENT_QUOTES, 'UTF-8'); ?>
                        <?php if ( $check['note'] ) : ?>
                            <br><small style="color: #DC2626;"><?php echo htmlspecialchars( $check['note'] , ENT_QUOTES, 'UTF-8'); ?></small>
                        <?php endif; ?>
                    </td>
                </tr>
            <?php endforeach; ?>
        </table>

        <?php if ( $all_pass ) : ?>
            <p style="color: #16A34A; font-weight: 600; margin-bottom: 16px;">
                &#10003; Your server meets all requirements. Ready to install!
            </p>
            <a href="?step=configure" class="sp-btn">Continue to Setup</a>
        <?php else : ?>
            <p style="color: #DC2626; font-weight: 600;">
                &#10005; Some requirements are not met. Please resolve the issues above before continuing.
            </p>
            <p style="margin-top: 12px;">
                <a href="?" class="sp-btn" style="background: #6B7280;">Re-Check Requirements</a>
            </p>
        <?php endif; ?>
        <?php
    } );
}


// ============================================================================
// STEP 2: CONFIGURATION FORM
// ============================================================================

/**
 * Show the main configuration form: database credentials + site info.
 *
 * WHY: We collect everything in one form so Harold only has to fill out one
 * page. The form is designed to be as clear as possible for someone who has
 * never seen a database configuration screen before.
 */
function sp_installer_show_form(): void {
    // WHY regenerate the session ID: a session ID observed while the visitor is
    // anonymous (e.g. on the requirements page) should not stay valid through
    // the install POST. Rotating it on each fresh visit to the form, deleting
    // the old session file (the true argument), closes that replay window.
    if ( session_status() === PHP_SESSION_ACTIVE ) {
        @session_regenerate_id( true );
    }

    // Generate a one-time nonce for CSRF protection
    $nonce = bin2hex( random_bytes( 16 ) );
    $_SESSION['sp_install_nonce'] = $nonce;

    // WHY: In demo mode the DB credentials must never reach the public HTML —
    // anyone viewing page source could read the real database password. Stash
    // the pre-verified demo credentials server-side in the session; the process
    // handler reads them from there instead of from $_POST. The form transmits
    // no DB fields at all in demo mode.
    if ( SP_INSTALLER_DEMO_MODE ) {
        $_SESSION['sp_demo_db'] = [
            'name'   => SP_DEMO_DB_NAME,
            'user'   => SP_DEMO_DB_USER,
            'pass'   => SP_DEMO_DB_PASS,
            'host'   => SP_DEMO_DB_HOST,
            'prefix' => 'wp_',
        ];
    }

    // Retrieve saved form data and errors from session (if returning from a failed attempt)
    $saved = $_SESSION['sp_form_data'] ?? [];
    $form_errors = $_SESSION['sp_form_errors'] ?? [];
    unset( $_SESSION['sp_form_errors'] );
    // Keep sp_form_data in session so it survives multiple error cycles

    sp_installer_render_page( 'Configure Your Site', function () use ( $nonce, $saved, $form_errors ) {
        ?>
        <?php if ( $form_errors ) : ?>
            <div style="background: #FEF2F2; border: 1px solid #FECACA; border-radius: 8px; padding: 16px 20px; margin-bottom: 24px;">
                <?php foreach ( $form_errors as $err ) : ?>
                    <p style="color: #991B1B; margin: 0 0 4px;"><?php echo htmlspecialchars( $err , ENT_QUOTES, 'UTF-8'); ?></p>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>
        <?php if ( SP_INSTALLER_DEMO_MODE ) : ?>
            <p style="margin-bottom: 24px; color: #6B7280;">
                This is what the SocietyPress installer looks like. The database fields below show
                example values — on your own hosting, you'd fill in the real credentials from your
                hosting provider. For this demo, just scroll down, enter a society name and password,
                and click Install.
            </p>
        <?php else : ?>
            <p style="margin-bottom: 24px; color: #6B7280;">
                You'll need your database credentials from your hosting provider. If you're not sure
                where to find them, check your hosting control panel (cPanel, Plesk, etc.) under
                "MySQL Databases" or "Database Management."
            </p>
        <?php endif; ?>

        <form method="post" action="?step=configure" id="sp-install-form">
            <input type="hidden" name="sp_install_nonce" value="<?php echo htmlspecialchars( $nonce , ENT_QUOTES, 'UTF-8'); ?>">

            <!-- Database Settings -->
            <h2 style="font-size: 18px; font-weight: 700; color: #0D1F3C; margin: 32px 0 16px; padding-bottom: 8px; border-bottom: 2px solid #C9973A;">
                Database Connection
            </h2>

            <?php if ( SP_INSTALLER_DEMO_MODE ) : ?>
                <!-- Demo mode: show realistic example fields (disabled) with coaching text.
                     Real credentials are NOT placed in the page — they live only in the
                     PHP session (set in sp_installer_show_form before render) and are read
                     server-side in sp_installer_process. Howard sees what he'll encounter
                     on his own hosting, with explanations, but no secret ever hits the HTML. -->

                <p style="color: #6B7280; font-size: 13px; margin-bottom: 16px;">
                    When you install on your own hosting, you'll fill in the database credentials
                    your hosting provider gives you. Here's what that looks like:
                </p>

                <table class="sp-form-table">
                    <tr>
                        <th><label>Database Name</label></th>
                        <td>
                            <input type="text" value="elmcounty_societypress" disabled readonly tabindex="-1"
                                   style="background: #F3F4F6; color: #9CA3AF; border: 1px dashed #D1D5DB; cursor: not-allowed; opacity: 0.7;">
                            <p class="desc">&larr; Your hosting provider gives you this when you create a MySQL database.</p>
                        </td>
                    </tr>
                    <tr>
                        <th><label>Database Username</label></th>
                        <td>
                            <input type="text" value="elmcounty_spadmin" disabled readonly tabindex="-1"
                                   style="background: #F3F4F6; color: #9CA3AF; border: 1px dashed #D1D5DB; cursor: not-allowed; opacity: 0.7;">
                            <p class="desc">&larr; Created at the same time as the database.</p>
                        </td>
                    </tr>
                    <tr>
                        <th><label>Database Password</label></th>
                        <td>
                            <input type="password" value="fake-password-example" disabled readonly tabindex="-1"
                                   style="background: #F3F4F6; color: #9CA3AF; border: 1px dashed #D1D5DB; cursor: not-allowed; opacity: 0.7;">
                            <p class="desc">&larr; The password you set when creating the database user.</p>
                        </td>
                    </tr>
                    <tr>
                        <th><label>Database Host</label></th>
                        <td>
                            <input type="text" value="localhost" disabled readonly tabindex="-1"
                                   style="background: #F3F4F6; color: #9CA3AF; border: 1px dashed #D1D5DB; cursor: not-allowed; opacity: 0.7;">
                            <p class="desc">&larr; Almost always "localhost." Only change if your host tells you to.</p>
                        </td>
                    </tr>
                    <tr>
                        <th><label>Table Prefix</label></th>
                        <td>
                            <input type="text" value="wp_" disabled readonly tabindex="-1"
                                   style="background: #F3F4F6; color: #9CA3AF; border: 1px dashed #D1D5DB; cursor: not-allowed; opacity: 0.7;">
                            <p class="desc">&larr; Leave as "wp_" unless you have a reason to change it.</p>
                        </td>
                    </tr>
                </table>

                <div style="background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 8px; padding: 12px 16px; margin: 16px 0 8px; font-size: 13px; color: #1E40AF;">
                    &#9432; For this demo, the database is pre-configured. Just fill in your society details below and click Install.
                </div>

            <?php else : ?>
                <!-- Production mode: full DB configuration form -->
                <p style="color: #6B7280; font-size: 13px; margin-bottom: 16px;">
                    Your hosting provider gave you these when you created a MySQL database.
                </p>

                <table class="sp-form-table">
                    <tr>
                        <th><label for="db_name">Database Name</label></th>
                        <td>
                            <input type="text" id="db_name" name="db_name" required
                                   value="<?php echo htmlspecialchars( $saved['db_name'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                                   placeholder="e.g., society_db" autocomplete="off">
                            <p class="desc">The name of the database you created for this site.</p>
                        </td>
                    </tr>
                    <tr>
                        <th><label for="db_user">Database Username</label></th>
                        <td>
                            <input type="text" id="db_user" name="db_user" required
                                   value="<?php echo htmlspecialchars( $saved['db_user'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                                   placeholder="e.g., society_user" autocomplete="off">
                        </td>
                    </tr>
                    <tr>
                        <th><label for="db_pass">Database Password</label></th>
                        <td>
                            <input type="password" id="db_pass" name="db_pass"
                                   placeholder="Your database password" autocomplete="new-password">
                        </td>
                    </tr>
                    <tr>
                        <th><label for="db_host">Database Host</label></th>
                        <td>
                            <input type="text" id="db_host" name="db_host" value="<?php echo htmlspecialchars( $saved['db_host'] ?? 'localhost' , ENT_QUOTES, 'UTF-8'); ?>">
                            <p class="desc">Almost always "localhost." Only change this if your host tells you to.</p>
                        </td>
                    </tr>
                    <tr>
                        <th><label for="db_prefix">Table Prefix</label></th>
                        <td>
                            <input type="text" id="db_prefix" name="db_prefix" value="<?php echo htmlspecialchars( $saved['db_prefix'] ?? 'wp_' , ENT_QUOTES, 'UTF-8'); ?>"
                                   pattern="[a-zA-Z_][a-zA-Z0-9_]*" maxlength="20">
                            <p class="desc">Leave as "wp_" unless you have a reason to change it.</p>
                        </td>
                    </tr>
                </table>
            <?php endif; ?>

            <!-- Site Settings -->
            <h2 style="font-size: 18px; font-weight: 700; color: #0D1F3C; margin: 32px 0 16px; padding-bottom: 8px; border-bottom: 2px solid #C9973A;">
                Your Society
            </h2>

            <table class="sp-form-table">
                <tr>
                    <th><label for="site_title">Society Name</label></th>
                    <td>
                        <input type="text" id="site_title" name="site_title" required
                               value="<?php echo htmlspecialchars( $saved['site_title'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                               placeholder="e.g., Elm County Genealogical Society">
                        <p class="desc">This appears as your site title. You can change it later.</p>
                    </td>
                </tr>
                <tr>
                    <th><label for="admin_email">Admin Email</label></th>
                    <td>
                        <input type="email" id="admin_email" name="admin_email" required
                               value="<?php echo htmlspecialchars( $saved['admin_email'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                               placeholder="you@example.com">
                        <p class="desc">Used for admin login and system notifications.</p>
                    </td>
                </tr>
                <tr>
                    <th><label for="admin_first">Your First Name</label></th>
                    <td>
                        <input type="text" id="admin_first" name="admin_first" required
                               value="<?php echo htmlspecialchars( $saved['admin_first'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                               placeholder="e.g., Harold">
                    </td>
                </tr>
                <tr>
                    <th><label for="admin_last">Your Last Name</label></th>
                    <td>
                        <input type="text" id="admin_last" name="admin_last" required
                               value="<?php echo htmlspecialchars( $saved['admin_last'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                               placeholder="e.g., Whitfield">
                    </td>
                </tr>
                <tr>
                    <th><label for="admin_user">Admin Username</label></th>
                    <td>
                        <input type="text" id="admin_user" name="admin_user" required
                               value="<?php echo htmlspecialchars( $saved['admin_user'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>" pattern="[a-zA-Z0-9_\-\.]{3,60}"
                               placeholder="e.g. jsmith or harold.whitfield">
                        <p class="desc">Your login username. Choose something unique and memorable.</p>
                        <p id="sp-admin-warn" style="display:none; color: #DC2626; font-size: 13px; margin-top: 4px;">
                            &#9888; Avoid common usernames like &ldquo;admin&rdquo; &mdash; they're the first thing attackers try.
                        </p>
                        <script>
                        document.getElementById('admin_user').addEventListener('input', function() {
                            var bad = ['admin', 'administrator', 'root', 'superadmin', 'user', 'test'];
                            var warn = document.getElementById('sp-admin-warn');
                            warn.style.display = bad.indexOf(this.value.toLowerCase().trim()) !== -1 ? '' : 'none';
                        });
                        </script>
                    </td>
                </tr>
                <tr>
                    <th><label for="admin_pass">Admin Password</label></th>
                    <td>
                        <input type="password" id="admin_pass" name="admin_pass" required
                               minlength="8" autocomplete="new-password">
                        <p class="desc">At least 8 characters. Make it strong — this protects your entire site.</p>
                    </td>
                </tr>
                <tr>
                    <th><label for="admin_pass2">Confirm Password</label></th>
                    <td>
                        <input type="password" id="admin_pass2" name="admin_pass2" required
                               minlength="8" autocomplete="new-password">
                    </td>
                </tr>
            </table>

            <!-- SocietyPress Settings -->
            <h2 style="font-size: 18px; font-weight: 700; color: #0D1F3C; margin: 32px 0 16px; padding-bottom: 8px; border-bottom: 2px solid #C9973A;">
                Society Details
            </h2>
            <p style="color: #6B7280; font-size: 13px; margin-bottom: 16px;">
                These are used on your public website and in emails to members.
                Everything here can be changed later.
            </p>

            <table class="sp-form-table">
                <tr>
                    <th><label for="org_address">Mailing Address</label></th>
                    <td>
                        <textarea id="org_address" name="org_address" rows="3"
                                  placeholder="Optional — P.O. Box or society address"><?php echo htmlspecialchars( $saved['org_address'] ?? '' , ENT_QUOTES, 'UTF-8'); ?></textarea>
                        <p class="desc">Your society's public address, not your personal home address. Leave blank if your society doesn't publish one.</p>
                    </td>
                </tr>
                <tr>
                    <th><label for="org_phone">Phone Number</label></th>
                    <td>
                        <input type="tel" id="org_phone" name="org_phone"
                               value="<?php echo htmlspecialchars( $saved['org_phone'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                               placeholder="Optional">
                        <p class="desc">Leave blank if your society doesn't have a public phone number.</p>
                    </td>
                </tr>
                <tr>
                    <th><label for="org_email">Contact Email</label></th>
                    <td>
                        <input type="email" id="org_email" name="org_email"
                               value="<?php echo htmlspecialchars( $saved['org_email'] ?? '' , ENT_QUOTES, 'UTF-8'); ?>"
                               placeholder="Optional — e.g., info@yoursociety.org">
                        <p class="desc">A public email for your society (not your personal email). Leave blank to set later.</p>
                    </td>
                </tr>
                <tr>
                    <th><label for="membership_period">Membership Period</label></th>
                    <td>
                        <?php $mp = $saved['membership_period'] ?? 'annual'; ?>
                        <select id="membership_period" name="membership_period">
                            <option value="annual" <?php echo $mp === 'annual' ? 'selected' : ''; ?>>Annual (fixed year)</option>
                            <option value="rolling" <?php echo $mp === 'rolling' ? 'selected' : ''; ?>>Rolling (12 months from join date)</option>
                            <option value="lifetime" <?php echo $mp === 'lifetime' ? 'selected' : ''; ?>>Lifetime only</option>
                        </select>
                        <p class="desc">Most societies use annual memberships with a fixed fiscal year.</p>
                    </td>
                </tr>
                <tr>
                    <th><label for="membership_start_month">Fiscal Year Starts</label></th>
                    <td>
                        <?php $ms = (int) ( $saved['membership_start'] ?? 7 ); ?>
                        <select id="membership_start_month" name="membership_start_month">
                            <?php
                            $months = [1=>'January',2=>'February',3=>'March',4=>'April',5=>'May',6=>'June',
                                       7=>'July',8=>'August',9=>'September',10=>'October',11=>'November',12=>'December'];
                            foreach ( $months as $num => $name ) {
                                $sel = $num === $ms ? ' selected' : '';
                                echo "<option value=\"{$num}\"{$sel}>{$name}</option>";
                            }
                            ?>
                        </select>
                        <p class="desc">When does your membership year begin? Many societies use July.</p>
                    </td>
                </tr>
            </table>

            <!-- Honeypot — bots fill this in, humans don't see it -->
            <div style="position: absolute; left: -9999px;" aria-hidden="true">
                <input type="text" name="sp_hp_field" tabindex="-1" autocomplete="off">
            </div>

            <div style="margin-top: 32px; text-align: center;">
                <button type="submit" class="sp-btn" id="sp-install-btn">
                    Install SocietyPress
                </button>
                <p style="color: #6B7280; font-size: 13px; margin-top: 12px;">
                    This will download and install WordPress + SocietyPress. It may take a minute or two.
                </p>
            </div>
        </form>

        <script>
        // Simple client-side password match check
        document.getElementById('sp-install-form').addEventListener('submit', function(e) {
            var p1 = document.getElementById('admin_pass').value;
            var p2 = document.getElementById('admin_pass2').value;
            if (p1 !== p2) {
                e.preventDefault();
                alert('Passwords do not match. Please re-enter.');
                document.getElementById('admin_pass2').focus();
                return;
            }
            // Show progress indicator
            var btn = document.getElementById('sp-install-btn');
            btn.disabled = true;
            btn.textContent = 'Installing... please wait';
            btn.style.opacity = '0.7';
        });
        </script>
        <?php
    } );
}


// ============================================================================
// STEP 3: PROCESS THE INSTALLATION
// ============================================================================

/**
 * Process the form submission and run the full installation.
 *
 * WHY: This is the core of the installer. Each step is wrapped in error
 * handling so we can give Harold a clear message about what went wrong if
 * anything fails. The order matters — we validate first, download second,
 * configure third, and clean up last.
 */
function sp_installer_process(): void {
    // CSRF check
    if ( ! isset( $_SESSION['sp_install_nonce'] ) || $_POST['sp_install_nonce'] !== $_SESSION['sp_install_nonce'] ) {
        sp_installer_die( 'Security Error', 'Invalid session. Please go back and try again.' );
    }
    unset( $_SESSION['sp_install_nonce'] );
    // WHY regenerate after consuming the nonce: rotate the session ID at the
    // transition from "anonymous form visitor" to "running the install" so a
    // previously observed ID cannot be replayed against the privileged step.
    // The true argument deletes the old session file.
    if ( session_status() === PHP_SESSION_ACTIVE ) {
        @session_regenerate_id( true );
    }

    // Honeypot check
    if ( ! empty( $_POST['sp_hp_field'] ) ) {
        // Bot detected — silently die
        http_response_code( 403 );
        exit;
    }

    // Collect and sanitize inputs.
    // WHY demo branch reads from session: in demo mode the DB credentials are
    // never sent through the form (so the password can't be read from page
    // source). They were stashed in the session when the form was rendered;
    // pull them from there and never trust $_POST for these four values.
    if ( SP_INSTALLER_DEMO_MODE && ! empty( $_SESSION['sp_demo_db'] ) ) {
        $demo_db   = $_SESSION['sp_demo_db'];
        $db_name   = trim( (string) ( $demo_db['name'] ?? '' ) );
        $db_user   = trim( (string) ( $demo_db['user'] ?? '' ) );
        $db_pass   = (string) ( $demo_db['pass'] ?? '' );
        $db_host   = trim( (string) ( $demo_db['host'] ?? 'localhost' ) );
        $db_prefix = preg_replace( '/[^a-zA-Z0-9_]/', '', (string) ( $demo_db['prefix'] ?? 'wp_' ) );
    } else {
        $db_name   = trim( $_POST['db_name'] ?? '' );
        $db_user   = trim( $_POST['db_user'] ?? '' );
        $db_pass   = $_POST['db_pass'] ?? '';
        $db_host   = trim( $_POST['db_host'] ?? 'localhost' );
        $db_prefix = preg_replace( '/[^a-zA-Z0-9_]/', '', $_POST['db_prefix'] ?? 'wp_' );
    }

    $site_title  = trim( $_POST['site_title'] ?? '' );
    $admin_email = trim( $_POST['admin_email'] ?? '' );
    $admin_first = trim( $_POST['admin_first'] ?? '' );
    $admin_last  = trim( $_POST['admin_last'] ?? '' );
    $admin_user  = trim( $_POST['admin_user'] ?? '' );
    $admin_pass  = $_POST['admin_pass'] ?? '';
    $admin_pass2 = $_POST['admin_pass2'] ?? '';

    // SocietyPress settings collected in the installer form
    $org_email          = trim( $_POST['org_email'] ?? '' );
    $org_address        = trim( $_POST['org_address'] ?? '' );
    $org_phone          = trim( $_POST['org_phone'] ?? '' );
    $membership_period  = trim( $_POST['membership_period'] ?? 'annual' );
    $membership_start   = (int) ( $_POST['membership_start_month'] ?? 7 );

    // WHY: Save all form values to the session so they survive errors.
    // If the DB connection fails or anything else goes wrong, Harold gets
    // sent back to the form with all his data still filled in. Nobody
    // should have to retype 12 fields because of a wrong DB password.
    // WHY no passwords: session files on shared hosting (/tmp/sess_*) can be
    // readable by other PHP processes on the same server. Repopulating a
    // password field is a convenience; a leaked DB or admin password is a
    // compromise. Both db_pass and admin_pass are deliberately omitted.
    $_SESSION['sp_form_data'] = [
        'db_name'   => $db_name,
        'db_user'   => $db_user,
        'db_host'   => $db_host,
        'db_prefix' => $db_prefix,
        'site_title'  => $site_title,
        'admin_email' => $admin_email,
        'admin_first' => $admin_first,
        'admin_last'  => $admin_last,
        'admin_user'  => $admin_user,
        'org_email'          => $org_email,
        'org_address'        => $org_address,
        'org_phone'          => $org_phone,
        'membership_period'  => $membership_period,
        'membership_start'   => $membership_start,
        // Passwords (db_pass, admin_pass) deliberately NOT saved to session
    ];

    // Validate
    $errors = [];
    if ( empty( $db_name ) )    $errors[] = 'Database name is required.';
    if ( empty( $db_user ) )    $errors[] = 'Database username is required.';
    if ( empty( $site_title ) ) $errors[] = 'Society name is required.';
    if ( empty( $admin_email ) || ! filter_var( $admin_email, FILTER_VALIDATE_EMAIL ) ) {
        $errors[] = 'A valid admin email address is required.';
    }
    if ( empty( $admin_first ) ) $errors[] = 'First name is required.';
    if ( empty( $admin_last ) )  $errors[] = 'Last name is required.';
    // WHY explicit pattern: the HTML pattern attribute is client-side only and
    // can be bypassed by anyone POSTing directly. The username flows into a
    // var_export() call that becomes part of a generated PHP file; while
    // var_export produces correct PHP literals (so it is not directly
    // injectable), defense-in-depth says we restrict to a known-safe set.
    if ( empty( $admin_user ) || ! preg_match( '/^[a-zA-Z0-9_\-.]{3,60}$/', $admin_user ) ) {
        $errors[] = 'Admin username must be 3–60 characters using only letters, numbers, underscore, hyphen, or period.';
    }
    if ( strlen( $admin_pass ) < 8 ) {
        $errors[] = 'Admin password must be at least 8 characters.';
    }
    if ( $admin_pass !== $admin_pass2 ) {
        $errors[] = 'Passwords do not match.';
    }
    if ( ! empty( $db_prefix ) && ! preg_match( '/^[a-zA-Z_][a-zA-Z0-9_]*$/', $db_prefix ) ) {
        $errors[] = 'Table prefix must start with a letter or underscore and contain only letters, numbers, and underscores.';
    }
    // WHY allowlist: $membership_period gets written into societypress_settings
    // and is rendered unescaped in some admin contexts. The HTML <select> only
    // exposes three values, but a direct POST can send anything. Pin to the
    // allowlist server-side; fall back to 'annual' for any unexpected value.
    if ( ! in_array( $membership_period, [ 'annual', 'rolling', 'lifetime' ], true ) ) {
        $membership_period = 'annual';
    }
    // WHY db_host pattern: this value flows into wp-config.php via a regex
    // replacement below. A value containing newlines, quotes, or PHP tokens
    // could escape the constant string and inject code. Restrict to the
    // characters legal in a hostname / hostname:port / unix-socket path.
    if ( $db_host === '' ) {
        $db_host = 'localhost';
    } elseif ( ! preg_match( '/^[a-zA-Z0-9._\-:\/]+$/', $db_host ) ) {
        $errors[] = 'Database host contains invalid characters. Use a hostname like "localhost" or "db.example.com" (port and unix-socket paths are allowed).';
    }
    if ( $errors ) {
        $_SESSION['sp_form_errors'] = $errors;
        header( 'Location: ?step=configure' );
        exit;
    }

    // Ensure prefix ends with underscore
    if ( substr( $db_prefix, -1 ) !== '_' ) {
        $db_prefix .= '_';
    }

    $install_dir = __DIR__;
    $log = [];

    // ---- 1. Test database connection ----
    // WHY try/catch: PHP 8.1+ throws mysqli_sql_exception on connection failure
    //      instead of returning an error code. The @ suppression operator doesn't
    //      catch exceptions, so without this try/catch the installer shows a raw
    //      fatal error instead of a friendly message.
    // WHY we capture the error NUMBER, not just the text: the numeric MySQL
    //      error code tells us *which* failure this is — wrong password (1045)
    //      vs. database missing (1049) vs. user-not-added-to-database (1044)
    //      vs. host-unreachable (2002/2003/2005). Each needs a completely
    //      different fix, and "check your credentials" is useless when the real
    //      problem is that the database user was never granted access to the
    //      database in cPanel — the single most common shared-host install snag.
    //      The number lets us hand the user the one specific fix.
    $log[] = 'Testing database connection...';
    // WHY force MYSQLI_REPORT_OFF: hosts disagree on the default. Some pre-set
    // MYSQLI_REPORT_STRICT (which throws), others leave it off (which sets
    // connect_errno). We turn reporting off and read connect_errno ourselves so
    // the branching below behaves identically on every host. The try/catch
    // stays as a backstop in case a host ignores this and throws anyway.
    if ( function_exists( 'mysqli_report' ) ) {
        @mysqli_report( MYSQLI_REPORT_OFF );
    }
    $db_errno = 0;
    $db_error = '';
    try {
        $conn = @new mysqli( $db_host, $db_user, $db_pass, $db_name );
        if ( $conn->connect_errno ) {
            $db_errno = (int) $conn->connect_errno;
            $db_error = (string) $conn->connect_error;
        } else {
            $conn->close();
        }
    } catch ( mysqli_sql_exception $e ) {
        $db_errno = (int) $e->getCode();
        $db_error = $e->getMessage();
    }
    if ( $db_errno || $db_error ) {
        // The raw message names the username, host, and database, so it still
        // goes only to the server error log. On screen we lead with a
        // plain-English diagnosis chosen from the error number and tuck the raw
        // text into a collapsible "Technical details" box for support calls.
        @error_log( 'SocietyPress installer: database connection failed (errno '
            . $db_errno . '): ' . $db_error );
        sp_installer_db_error_page( $db_errno, $db_error, $db_host, $db_name, $db_user );
    }
    $log[] = 'Database connection successful.';

    // ---- 2. Download WordPress ----
    $log[] = 'Downloading WordPress...';
    $wp_zip_path = $install_dir . '/wordpress-latest.zip';
    $wp_downloaded = sp_installer_download( SP_INSTALLER_WP_URL, $wp_zip_path );
    if ( ! $wp_downloaded ) {
        sp_installer_die(
            'WordPress Couldn’t Be Downloaded',
            'The installer couldn’t download WordPress from wordpress.org. This usually means your host is blocking outbound connections, or the connection timed out.',
            $GLOBALS['sp_installer_last_download_error'] ?? '',
            [
                'Click Go Back and try again — a brief network hiccup is the most common cause.',
                'If it keeps failing, ask your host to allow outbound HTTPS connections to wordpress.org.',
            ]
        );
    }
    $log[] = 'WordPress downloaded.';

    // ---- 3. Extract WordPress ----
    $log[] = 'Extracting WordPress...';
    $zip = new ZipArchive();
    $wp_zip_open = $zip->open( $wp_zip_path );
    if ( $wp_zip_open !== true ) {
        @unlink( $wp_zip_path );
        sp_installer_die(
            'The WordPress Download Couldn’t Be Opened',
            'WordPress downloaded, but the file couldn’t be opened as a ZIP archive. This almost always means the download was cut off partway, or your host returned an error page instead of the actual file.',
            sp_installer_zip_err( $wp_zip_open ),
            [
                'Click Go Back and run the install again — a half-finished download is the most common cause.',
                'If it keeps failing, your host may be blocking downloads from wordpress.org. Ask them to allow outbound connections to wordpress.org.',
            ]
        );
    }

    // WordPress ZIP extracts to a "wordpress/" subdirectory — we need to move
    // the contents up to the install directory.
    //
    // SECURITY: We do NOT use $zip->extractTo() because that performs no path
    // validation. A ZIP entry named "../../etc/cron.d/evil" would escape the
    // install directory and write anywhere the web user can reach. The
    // wordpress.org ZIP is trusted, but trust is not a control — a MITM, a
    // compromised mirror, or a corrupt download could deliver a crafted ZIP.
    // We iterate entries manually and reject any whose normalized path falls
    // outside the install directory.
    $install_dir_real = realpath( $install_dir );
    if ( $install_dir_real === false ) {
        $zip->close();
        sp_installer_die( 'Extract Failed', 'Install directory could not be resolved.' );
    }
    for ( $i = 0; $i < $zip->numFiles; $i++ ) {
        $entry_name = $zip->getNameIndex( $i );
        if ( $entry_name === false || $entry_name === '' ) {
            continue;
        }
        // Reject any entry containing path-traversal sequences or null bytes,
        // and reject absolute paths (Windows drive letters too).
        if ( strpos( $entry_name, "\0" ) !== false
             || strpos( $entry_name, '..' ) !== false
             || $entry_name[0] === '/'
             || preg_match( '/^[A-Za-z]:/', $entry_name ) ) {
            $zip->close();
            sp_installer_die( 'Extract Failed', 'WordPress archive contains an unsafe entry path. Aborting for safety.' );
        }
        $target  = $install_dir_real . DIRECTORY_SEPARATOR . $entry_name;
        $is_dir  = ( substr( $entry_name, -1 ) === '/' );
        if ( $is_dir ) {
            if ( ! is_dir( $target ) ) {
                @mkdir( $target, 0755, true );
            }
            continue;
        }
        $parent = dirname( $target );
        if ( ! is_dir( $parent ) ) {
            @mkdir( $parent, 0755, true );
        }
        // Final containment check: the resolved parent directory must be the
        // install dir or beneath it. realpath() returns false if the path
        // doesn't exist, but mkdir above guarantees it does at this point.
        // WHY trailing DIRECTORY_SEPARATOR: a bare strpos prefix-match would
        // accept '/var/www/htmlevil' as inside '/var/www/html'. The trailing
        // separator forces a directory-boundary match.
        $parent_real = realpath( $parent );
        $needle      = rtrim( $install_dir_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
        $haystack    = rtrim( $parent_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
        if ( $parent_real === false || strpos( $haystack, $needle ) !== 0 ) {
            $zip->close();
            sp_installer_die( 'Extract Failed', 'WordPress archive entry resolved outside the install directory. Aborting for safety.' );
        }
        $stream = $zip->getStream( $entry_name );
        if ( $stream === false ) {
            continue;
        }
        $out = fopen( $target, 'wb' );
        if ( $out !== false ) {
            stream_copy_to_stream( $stream, $out );
            fclose( $out );
        }
        if ( is_resource( $stream ) ) {
            fclose( $stream );
        }
    }
    $zip->close();
    @unlink( $wp_zip_path );

    // Move files from wordpress/ subdirectory to install root.
    // SECURITY: Source paths come from our own filesystem walk after the
    // safe extraction above, so path traversal is not a concern here, but
    // we still verify each target is inside $install_dir as belt-and-braces.
    $wp_subdir = $install_dir . '/wordpress';
    if ( is_dir( $wp_subdir ) ) {
        $items = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator( $wp_subdir, RecursiveDirectoryIterator::SKIP_DOTS ),
            RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ( $items as $item ) {
            $relative = substr( $item->getPathname(), strlen( $wp_subdir ) + 1 );
            if ( strpos( $relative, '..' ) !== false ) {
                continue; // belt-and-braces; should never trigger after safe extract
            }
            $target = $install_dir . '/' . $relative;
            if ( $item->isDir() ) {
                @mkdir( $target, 0755, true );
            } else {
                // WHY copy+unlink fallback: on many cPanel/shared hosts the temp
                // extraction and the install root can sit on different mount
                // points, where rename() across devices fails silently. If the
                // move fails, copy then delete the source — a universally
                // reliable cross-mount pattern. If even the copy fails, abort
                // loudly rather than logging a misleading "extracted" success.
                if ( ! @rename( $item->getPathname(), $target ) ) {
                    if ( ! @copy( $item->getPathname(), $target ) ) {
                        sp_installer_die( 'Extract Failed', 'Could not move WordPress files into place. Check directory permissions and available disk space.' );
                    }
                    @unlink( $item->getPathname() );
                }
            }
        }
        // Clean up the now-empty wordpress directory
        sp_installer_rmdir( $wp_subdir );
    }
    $log[] = 'WordPress extracted.';

    // ---- 4. Generate security keys ----
    $log[] = 'Generating security keys...';
    $salts = sp_installer_download_string( SP_INSTALLER_SALT_URL );
    if ( ! $salts || strpos( $salts, 'AUTH_KEY' ) === false ) {
        // Fallback: generate our own keys if the API is unreachable
        $salts = '';
        $key_names = [
            'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
            'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
        ];
        foreach ( $key_names as $name ) {
            $salts .= "define( '{$name}', '" . bin2hex( random_bytes( 32 ) ) . "' );\n";
        }
    }
    $log[] = 'Security keys generated.';

    // ---- 5. Write wp-config.php ----
    $log[] = 'Writing configuration file...';
    $config_sample = $install_dir . '/wp-config-sample.php';
    if ( ! file_exists( $config_sample ) ) {
        sp_installer_die( 'Configuration Error', 'wp-config-sample.php not found. The WordPress download may be corrupted.' );
    }

    $config = file_get_contents( $config_sample );

    // Replace database constants.
    // WHY preg_replace per constant (not str_replace): wp-config-sample.php
    // contains the literal "localhost" in inline comments AND in the DB_HOST
    // line. A naive str_replace( "localhost", $db_host, $config ) would also
    // overwrite every comment containing the word — and worse, the user's
    // db_host value would land verbatim in those positions, including any
    // quotes or PHP tokens it contained. Targeting only the constant's
    // single-quoted string and addslashes()-escaping the user value
    // contains both problems.
    // WHY preg_replace_callback (not preg_replace): a literal `$1` or `${1}`
    // in the user's value (cPanel-style auto-generated credentials sometimes
    // include literal dollar signs followed by digits) would be parsed as
    // a backreference inside a preg_replace replacement string and splice
    // the captured group back in place. addslashes() doesn't touch `$`.
    // The callback form treats the replacement as a plain string.
    $sp_set_config_value = function ( string $constant, string $value, string $config ): string {
        $escaped = addslashes( $value ); // safe for single-quoted PHP literal
        $pattern = "/(define\(\s*'" . preg_quote( $constant, '/' ) . "'\s*,\s*')[^']*('\s*\)\s*;)/";
        return preg_replace_callback( $pattern, function ( $m ) use ( $escaped ) {
            return $m[1] . $escaped . $m[2];
        }, $config, 1 );
    };
    $config = $sp_set_config_value( 'DB_NAME',     $db_name, $config );
    $config = $sp_set_config_value( 'DB_USER',     $db_user, $config );
    $config = $sp_set_config_value( 'DB_PASSWORD', $db_pass, $config );
    $config = $sp_set_config_value( 'DB_HOST',     $db_host, $config );
    $sp_prefix_safe = $db_prefix; // already validated as [a-zA-Z0-9_] earlier
    $config = preg_replace_callback(
        '/\$table_prefix\s*=\s*\'wp_\'/',
        function () use ( $sp_prefix_safe ) { return "\$table_prefix = '{$sp_prefix_safe}'"; },
        $config
    );

    // Replace salt block — use a callback so any `$1`-style sequence in the
    // upstream WP salt response (or a MITM-injected one) is treated as a
    // literal, not as a backreference.
    $sp_salts_block = trim( $salts ) . "\n\n";
    $config = preg_replace_callback(
        '/define\(\s*\'AUTH_KEY\'.*?define\(\s*\'NONCE_SALT\'[^;]*;\s*/s',
        function () use ( $sp_salts_block ) { return $sp_salts_block; },
        $config
    );

    // Add debug settings (off for production)
    $config = str_replace(
        "define( 'WP_DEBUG', false );",
        "define( 'WP_DEBUG', false );\ndefine( 'WP_DEBUG_LOG', false );\ndefine( 'WP_DEBUG_DISPLAY', false );",
        $config
    );

    if ( false === @file_put_contents( $install_dir . '/wp-config.php', $config ) ) {
        $write_err = error_get_last();
        sp_installer_die(
            'Couldn’t Save the Configuration File',
            'WordPress downloaded fine, but the installer couldn’t write its configuration file (wp-config.php) into this folder. This is almost always a file-permissions problem.',
            $write_err['message'] ?? '',
            [
                'In cPanel → File Manager, make sure the folder this installer is in is writable — its permissions should be 755.',
                'If you’re not sure how, your host’s support can set the correct permissions in a few seconds.',
                'Also confirm you haven’t run out of disk space.',
            ]
        );
    }
    $log[] = 'Configuration file written.';

    // ---- 6. Prepare for WordPress installation ----
    // WHY: We can't bootstrap wp-settings.php on this host (proc_open disabled,
    // and WordPress's DB error handler calls die() which can't be caught). Instead,
    // we let WordPress install itself via its own /wp-admin/install.php, and we
    // plant a must-use plugin that auto-activates SocietyPress + theme on first load.
    $log[] = 'Preparing WordPress installation...';

    // Create the mu-plugin that fires once after WP installs
    $mu_dir = $install_dir . '/wp-content/mu-plugins';
    @mkdir( $mu_dir, 0755, true );

    $mu_plugin = <<<'MUPLUGIN'
<?php
/**
 * SocietyPress Auto-Activator (must-use plugin)
 *
 * Two jobs, in two separate requests:
 *
 * 1. login_init hook — Auto-login. The bridge script stores a one-time
 *    transient in the DB containing the admin user ID plus a random 256-bit
 *    secret, then redirects the browser to wp-login.php with that secret in
 *    the sp_token query parameter. This hook reads the transient, verifies
 *    the secret matches sp_token via hash_equals (constant-time), deletes
 *    the transient, sets the auth cookie, and redirects to wp-admin.
 *
 *    WHY a secret, not just a file: without the secret, any request to
 *    wp-login.php between bridge-completion and legitimate first visit
 *    would trigger auto-login as admin. The secret is 256 bits of random
 *    and is only ever in the redirect URL the bridge just sent, so only
 *    the user whose browser followed that redirect can log in.
 *
 *    WHY a transient, not a file: the token file would sit in wp-content,
 *    which is web-accessible on most hosts. A crafted GET to the token
 *    filename could leak the secret. Transients live in the DB and are
 *    not reachable over HTTP.
 *
 * 2. admin_init hook — Activate SocietyPress + parent theme, apply installer
 *    settings, clean up default WordPress cruft, and self-destruct.
 */

// ---- Auto-login on first hit to wp-login.php ----
add_action( 'login_init', function () {
    $data = get_transient( 'sp_auto_login' );
    if ( ! is_array( $data ) ) {
        return;
    }

    $user_id = (int) ( $data['user_id'] ?? 0 );
    $secret  = (string) ( $data['secret']  ?? '' );
    $provided = (string) ( $_GET['sp_token'] ?? '' );

    if ( $user_id < 1 || $secret === '' || $provided === '' ) {
        // Leave the transient in place so the legitimate click (which carries
        // the correct token) can still consume it. The transient expires on
        // its own after a few minutes.
        return;
    }

    if ( ! hash_equals( $secret, $provided ) ) {
        // A present-but-wrong token can never be the legitimate click — the
        // browser that followed the bridge redirect carries the exact correct
        // token. So a mismatch is always a probe (crawler/scanner/attacker).
        // Burn the transient immediately to close the brute-force window
        // instead of leaving it alive for its full TTL.
        delete_transient( 'sp_auto_login' );
        return;
    }

    // Secret matched. Single-use: delete before authenticating.
    delete_transient( 'sp_auto_login' );

    // Set the auth cookie in a normal WordPress context where COOKIEPATH,
    // COOKIE_DOMAIN, and session token infrastructure are all correct.
    wp_set_auth_cookie( $user_id, true );

    // Redirect to wp-admin — the admin_init hook below will handle the rest.
    wp_safe_redirect( admin_url() );
    exit;
} );

// Recursive directory delete for removing default themes.
// WHY: PHP has no built-in rmdir-recursive, and we can't rely on the
// installer's sp_installer_rmdir() since this mu-plugin runs in a
// separate request after WordPress is fully installed.
function sp_installer_mu_rmdir( string $dir ): void {
    if ( ! is_dir( $dir ) ) return;
    $items = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ( $items as $item ) {
        if ( $item->isDir() ) {
            @rmdir( $item->getPathname() );
        } else {
            @unlink( $item->getPathname() );
        }
    }
    @rmdir( $dir );
}

add_action( 'admin_init', function () {
    // Only run once — if SocietyPress is already active, bail
    $active = get_option( 'active_plugins', [] );
    if ( in_array( 'societypress/societypress.php', $active, true ) ) {
        // Clean up: delete this mu-plugin
        @unlink( __FILE__ );
        return;
    }

    // ---- Cleanup FIRST: remove WordPress default cruft ----
    // WHY: This MUST happen before SocietyPress activation. The plugin's
    // activation hook runs sp_maybe_create_default_pages(), which only
    // creates a Home page if zero published pages exist. WordPress ships
    // with a "Sample Page" that counts as a published page — if we don't
    // delete it first, the activation hook sees it and skips homepage
    // creation, leaving show_on_front stuck on "posts."

    // Delete "Hello world!" post, "Sample Page", and default comment
    $hello_post = get_page_by_path( 'hello-world', OBJECT, 'post' );
    if ( $hello_post ) { wp_delete_post( $hello_post->ID, true ); }

    $sample_page = get_page_by_path( 'sample-page' );
    if ( $sample_page ) { wp_delete_post( $sample_page->ID, true ); }

    $default_comments = get_comments( [ 'number' => 100 ] );
    foreach ( $default_comments as $c ) {
        wp_delete_comment( $c->comment_ID, true );
    }

    // Delete Hello Dolly — both the single-file and directory variants
    $hello_file = WP_PLUGIN_DIR . '/hello.php';
    if ( file_exists( $hello_file ) ) { @unlink( $hello_file ); }
    $hello_dir = WP_PLUGIN_DIR . '/hello-dolly';
    if ( is_dir( $hello_dir ) ) { sp_installer_mu_rmdir( $hello_dir ); }

    // Delete default Twenty* themes — SocietyPress is the only theme needed.
    // WHY glob instead of a hardcoded list: each WordPress release ships a new
    // Twenty* theme. A fixed list goes stale and leaves the newest default on
    // disk after every WP upgrade. Matching any twenty* directory future-proofs
    // the cleanup with no maintenance.
    $twenty_dirs = glob( get_theme_root() . '/twenty*', GLOB_ONLYDIR );
    if ( is_array( $twenty_dirs ) ) {
        foreach ( $twenty_dirs as $theme_dir ) {
            sp_installer_mu_rmdir( $theme_dir );
        }
    }

    // ---- NOW activate SocietyPress ----
    // WHY activate_plugin() instead of update_option('active_plugins'):
    // Manually adding to the active_plugins array skips register_activation_hook.
    // That means sp_maybe_create_default_pages(), sp_create_tables(), and all
    // the other activation setup never runs. activate_plugin() loads the plugin
    // file, fires the activation hook, and does everything properly.
    require_once ABSPATH . 'wp-admin/includes/plugin.php';
    activate_plugin( 'societypress/societypress.php' );

    // Activate parent theme
    switch_theme( 'societypress' );

    // Set permalinks
    global $wp_rewrite;
    $wp_rewrite->set_permalink_structure( '/%postname%/' );
    $wp_rewrite->flush_rules( true );

    // ---- Apply installer-collected SocietyPress settings ----
    // WHY: The installer form collects org details and membership config so
    // Harold doesn't have to re-enter them in a separate setup wizard. The
    // installer writes these to a JSON file with a randomized filename
    // (sp-installer-config-<32hex>.json) that we glob for here, read,
    // merge into the SP settings, and delete.
    // WHY initialize to null: $installer_config is only assigned inside the
    // config-file block below. If glob finds nothing (write failed, or another
    // process already cleaned the file), the is_array() check further down would
    // otherwise read an undefined variable — a PHP 8 warning that silently skips
    // admin member-record creation. Initialize so the guard is real, not luck.
    $installer_config = null;

    // WHY glob two locations: the installer writes this file one directory
    // above the web root when that parent is writable (so it can't be fetched
    // over HTTP), and falls back to ABSPATH otherwise. Check both.
    $config_file = '';
    $candidates  = array_merge(
        glob( ABSPATH . 'sp-installer-config-*.json' ) ?: [],
        glob( dirname( untrailingslashit( ABSPATH ) ) . '/sp-installer-config-*.json' ) ?: []
    );
    // WHY validate the basename: glob only matched a wildcard. Pin each
    // candidate to the exact randomized pattern (32 hex chars) so a file
    // planted via some other writable-path vector can't be read as config.
    $candidates = array_values( array_filter( $candidates, function ( $path ) {
        return (bool) preg_match( '/^sp-installer-config-[a-f0-9]{32}\.json$/', basename( $path ) );
    } ) );
    if ( $candidates ) {
        // Most recent wins if more than one exists (shouldn't, but defend).
        usort( $candidates, function ( $a, $b ) { return filemtime( $b ) <=> filemtime( $a ); } );
        $config_file = $candidates[0];
    }
    if ( $config_file && file_exists( $config_file ) ) {
        $installer_config = json_decode( file_get_contents( $config_file ), true );
        if ( is_array( $installer_config ) ) {
            $sp_settings = get_option( 'societypress_settings', [] );
            // Map installer fields to SP settings keys
            $sp_settings['organization_name']    = $installer_config['organization_name'] ?? $sp_settings['organization_name'] ?? '';
            $sp_settings['organization_email']   = $installer_config['organization_email'] ?? $sp_settings['organization_email'] ?? '';
            $sp_settings['organization_address'] = $installer_config['organization_address'] ?? '';
            $sp_settings['organization_phone']   = $installer_config['organization_phone'] ?? '';
            $sp_settings['membership_period_type'] = $installer_config['membership_period_type'] ?? 'annual';
            $sp_settings['membership_start_month'] = (int) ( $installer_config['membership_start_month'] ?? 7 );
            // Email from defaults to org name + email
            $sp_settings['email_from_name']  = $sp_settings['email_from_name'] ?: $sp_settings['organization_name'];
            $sp_settings['email_from_email'] = $sp_settings['email_from_email'] ?: $sp_settings['organization_email'];

            // Video hero defaults — the theme ships with a cinematic background
            // video that plays behind the society name on the home page. This is
            // the "blow their minds" first impression after install.
            $theme_url = get_template_directory_uri();
            if ( empty( $sp_settings['homepage_hero_type'] ) ) {
                $sp_settings['homepage_hero_type']     = 'video';
                $sp_settings['homepage_hero_media']    = $theme_url . '/assets/hero-background.mp4';
                $sp_settings['homepage_hero_poster']   = $theme_url . '/assets/hero-background-poster.jpg';
                $sp_settings['homepage_hero_headline'] = '';
                $sp_settings['homepage_hero_subtitle'] = 'Preserving Our Past. Connecting Our Present.';
                $sp_settings['homepage_hero_cta_text'] = 'Upcoming Events';
                $sp_settings['homepage_hero_cta_url']  = '/events/';
                $sp_settings['homepage_hero_overlay']  = 35;
                $sp_settings['homepage_hero_height']   = 'fullscreen';
            }

            update_option( 'societypress_settings', $sp_settings );

            // WHY: Don't mark wizard complete here — the wizard now handles
            //      branding uploads, package selection, and nav menu setup.
            //      The installer only covers org info and membership basics.
        }
        @unlink( $config_file );
    }

    // ---- Create a member record + set WP profile for the admin user ----
    // WHY: The person installing SocietyPress is almost always a member.
    // The installer collected their first/last name. We save it to the WP
    // user profile (so WordPress knows their name) and create an SP member
    // record (so the header shows their name instead of "Log In").
    $admin_user = wp_get_current_user();
    if ( $admin_user && $admin_user->ID && is_array( $installer_config ) ) {
        $first = $installer_config['admin_first_name'] ?? '';
        $last  = $installer_config['admin_last_name'] ?? '';

        // Save to WordPress user profile
        if ( $first ) update_user_meta( $admin_user->ID, 'first_name', $first );
        if ( $last )  update_user_meta( $admin_user->ID, 'last_name', $last );
        if ( $first || $last ) {
            wp_update_user( [
                'ID'           => $admin_user->ID,
                'display_name' => trim( $first . ' ' . $last ),
            ] );
        }

        // Create SP member record
        global $wpdb;
        $members_table = $wpdb->prefix . 'sp_members';
        $exists = $wpdb->get_var( $wpdb->prepare(
            "SELECT user_id FROM {$members_table} WHERE user_id = %d",
            $admin_user->ID
        ) );
        if ( ! $exists ) {
            if ( empty( $first ) ) $first = $admin_user->user_login;
            $wpdb->insert( $members_table, [
                'user_id'    => $admin_user->ID,
                'first_name' => $first,
                'last_name'  => $last,
                'status'     => 'active',
                'join_date'  => current_time( 'Y-m-d' ),
            ] );
        }
    }

    // Self-destruct
    @unlink( __FILE__ );

    // Redirect to the setup wizard — Harold still needs to upload branding,
    // choose packages, and set up navigation.
    wp_safe_redirect( admin_url( 'admin.php?page=sp-setup-wizard' ) );
    exit;
}, 1 );
MUPLUGIN;

    if ( false === @file_put_contents( $mu_dir . '/sp-auto-activate.php', $mu_plugin ) ) {
        $mu_err = error_get_last();
        sp_installer_die(
            'Couldn’t Finish Setting Up',
            'WordPress and SocietyPress are downloaded, but the installer couldn’t write a small helper file it needs to finish activating SocietyPress. This is a file-permissions or disk-space problem in the wp-content folder.',
            $mu_err['message'] ?? '',
            [
                'In cPanel → File Manager, confirm the wp-content folder is writable (permissions 755).',
                'Confirm you haven’t run out of disk space.',
                'Then click Go Back and try again.',
            ]
        );
    }
    $log[] = 'Auto-activator planted.';

    // ---- 6b. Write installer config for the mu-plugin to pick up ----
    // WHY: The mu-plugin runs in a separate request where $_POST is gone.
    // We write the SP settings to a JSON file that the mu-plugin reads,
    // applies to the societypress_settings option, and deletes. This is
    // how the installer-collected org details reach the plugin without
    // a second wizard step.
    //
    // WHY randomized filename: the previous fixed name `sp-installer-
    // config.json` was web-readable for the few seconds between write and
    // mu-plugin pickup. The file holds admin name/email/phone/address
    // (no passwords). Randomizing the filename closes blind URL probing
    // — the mu-plugin globs for the unique pattern.
    //
    // WHY one directory above the web root when possible: even with a random
    // name, the file sits with no .htaccess protection (we rename .htaccess
    // away below so the bridge is reachable). Writing it one level above
    // __DIR__ takes it out of the document root entirely so it can never be
    // fetched over HTTP. If that parent isn't writable (e.g. a root install),
    // fall back to the install dir. The mu-plugin globs both locations.
    $installer_config = [
        'organization_name'    => $site_title,
        'organization_email'   => $org_email ?: '',
        'organization_address' => $org_address,
        'organization_phone'   => $org_phone,
        'membership_period_type' => $membership_period,
        'membership_start_month' => $membership_start,
        'admin_first_name'     => $admin_first,
        'admin_last_name'      => $admin_last,
    ];
    $config_filename = 'sp-installer-config-' . bin2hex( random_bytes( 16 ) ) . '.json';
    $config_parent   = dirname( $install_dir );
    $config_dir      = ( $config_parent && is_dir( $config_parent ) && is_writable( $config_parent ) )
        ? $config_parent
        : $install_dir;
    if ( false === file_put_contents( $config_dir . '/' . $config_filename, json_encode( $installer_config ) ) ) {
        // Last resort: try the install dir if the out-of-webroot write failed.
        @file_put_contents( $install_dir . '/' . $config_filename, json_encode( $installer_config ) );
    }
    $log[] = 'Installer config written.';

    // ---- 7. Create a bridge script that runs WordPress's install with our data ----
    // WHY: We already collected the society name, admin email, username, and password
    // in our form. Redirecting to wp-admin/install.php makes the user enter it all
    // again — terrible UX. Instead, we create a temporary PHP script that loads
    // WordPress and runs wp_install() with the values we already have. The user
    // never sees WordPress's install screen.
    $log[] = 'Creating install bridge...';

    // Generate a one-time secret token for this bridge invocation.
    // WHY: The bridge script is publicly reachable for the brief window between
    // it being written and the browser hitting it. A random token in the URL
    // ensures only the user whose browser was redirected here can trigger it —
    // a crawler or scanner that hits /sp-bridge-install.php without the token
    // gets a 403 instead of running wp_install().
    $bridge_token = bin2hex( random_bytes( 16 ) );

    $bridge_script = $install_dir . '/sp-bridge-install.php';
    $bridge_code = '<?php' . "\n"
        . '/**' . "\n"
        . ' * Temporary bridge script — runs wp_install() with pre-collected data,' . "\n"
        . ' * sets up permalinks, and self-destructs. The user\'s browser is redirected' . "\n"
        . ' * here by the SocietyPress installer so WordPress installs in a normal' . "\n"
        . ' * HTTP request context (avoiding bootstrap issues).' . "\n"
        . ' */' . "\n"
        . "\n"
        . '// Token guard — only the redirect from the SocietyPress installer carries' . "\n"
        . '// the correct token. Anything else (crawlers, scanners, direct visits) gets' . "\n"
        . '// a 403 before WordPress is loaded or wp_install() is touched.' . "\n"
        . 'if ( ! isset( $_GET[\'token\'] ) || $_GET[\'token\'] !== ' . var_export( $bridge_token, true ) . ' ) {' . "\n"
        . '    http_response_code( 403 );' . "\n"
        . '    die( \'Unauthorized\' );' . "\n"
        . '}' . "\n"
        . "\n"
        . 'define( "WP_INSTALLING", true );' . "\n"
        . 'require_once __DIR__ . "/wp-load.php";' . "\n"
        . 'require_once ABSPATH . "wp-admin/includes/upgrade.php";' . "\n"
        . "\n"
        . '// WHY the admin password comes from the session, not from this file:' . "\n"
        . '// writing the plaintext password into a PHP file in the web root exposes' . "\n"
        . '// it to any co-tenant process under the same system user (shared hosting)' . "\n"
        . '// for the window before this script self-deletes, and indefinitely if the' . "\n"
        . '// self-delete ever fails. The installer stored it in $_SESSION before the' . "\n"
        . '// redirect; the browser carries the session cookie here, so we read it' . "\n"
        . '// server-side and immediately unset it. Never written to disk.' . "\n"
        . 'if ( session_status() !== PHP_SESSION_ACTIVE ) { session_start(); }' . "\n"
        . '$sp_admin_pass = (string) ( $_SESSION["sp_bridge_pass"] ?? "" );' . "\n"
        . 'unset( $_SESSION["sp_bridge_pass"] );' . "\n"
        . "\n"
        . '// var_export() produces valid PHP string literals with proper escaping of quotes' . "\n"
        . '// and special characters, making it safe for embedding user input into generated PHP.' . "\n"
        . '// This is intentional — do not replace with string concatenation.' . "\n"
        . '// Run the WordPress installation with data from the SocietyPress installer' . "\n"
        . '$result = wp_install(' . "\n"
        . '    ' . var_export( $site_title, true ) . ',' . "\n"
        . '    ' . var_export( $admin_user, true ) . ',' . "\n"
        . '    ' . var_export( $admin_email, true ) . ',' . "\n"
        . '    true,' . "\n"
        . '    "",' . "\n"
        . '    $sp_admin_pass' . "\n"
        . ');' . "\n"
        . "\n"
        . 'if ( is_wp_error( $result ) ) {' . "\n"
        . '    wp_die( "Installation failed: " . $result->get_error_message() );' . "\n"
        . '}' . "\n"
        . "\n"
        . '// Write a one-time auto-login transient for the mu-plugin to pick up.' . "\n"
        . '// WHY: wp_set_auth_cookie() does not work reliably during WP_INSTALLING' . "\n"
        . '// because cookie constants (COOKIEPATH, COOKIE_DOMAIN) are derived from' . "\n"
        . '// the siteurl option, which is set AFTER wp_install() runs but the' . "\n"
        . '// constants were already defined (with wrong values) during bootstrap.' . "\n"
        . '// Instead, we hand a (user_id, secret) pair to the mu-plugin which runs' . "\n"
        . '// in a normal WordPress context where everything is properly initialized.' . "\n"
        . '// WHY a secret: without one, any hit to wp-login.php between now and the' . "\n"
        . '// legitimate browser following the redirect below would trigger auto-login' . "\n"
        . '// as admin. The secret is 256 bits of random — only carried in the URL we' . "\n"
        . '// are about to redirect to — so only that one request can consume the token.' . "\n"
        . '// WHY a transient (not a file): a token file in wp-content is web-reachable' . "\n"
        . '// on most hosts. Transients live in the DB and cannot be read over HTTP.' . "\n"
        . '$sp_token_secret = bin2hex( random_bytes( 32 ) );' . "\n"
        . 'set_transient( "sp_auto_login", array(' . "\n"
        . '    "user_id" => (int) $result["user_id"],' . "\n"
        . '    "secret"  => $sp_token_secret,' . "\n"
        . '), 5 * MINUTE_IN_SECONDS );' . "\n"
        . "\n"
        . '// Self-destruct — remove bridge script and main installer' . "\n"
        . '@unlink( __FILE__ );' . "\n"
        . '$installer = dirname( __FILE__ ) . "/sp-installer.php";' . "\n"
        . 'if ( file_exists( $installer ) ) { @unlink( $installer ); }' . "\n"
        . '// Also remove the demo sample-data loader — a dev/demo utility that' . "\n"
        . '// must not linger in the web root of a finished install.' . "\n"
        . '$sample = dirname( __FILE__ ) . "/load-sample-data.php";' . "\n"
        . 'if ( file_exists( $sample ) ) { @unlink( $sample ); }' . "\n"
        . '// WHY: leftover .htaccess.sp-bak would otherwise sit web-readable' . "\n"
        . '// on Apache hosts forever, exposing the prior rewrite config.' . "\n"
        . '$htaccess_bak = dirname( __FILE__ ) . "/.htaccess.sp-bak";' . "\n"
        . 'if ( file_exists( $htaccess_bak ) ) { @unlink( $htaccess_bak ); }' . "\n"
        . "\n"
        . '// Redirect directly to wp-login.php carrying the secret. The mu-plugin\'s' . "\n"
        . '// login_init hook reads the transient, verifies sp_token matches the stored' . "\n"
        . '// secret, sets the auth cookie, and redirects to wp-admin.' . "\n"
        . '// WHY home_url() not SCRIPT_NAME: WordPress is loaded here (wp-load.php' . "\n"
        . '// above), so home_url() is the authoritative, host-config-agnostic base.' . "\n"
        . '// SCRIPT_NAME can be wrong on Nginx/cPanel and is spoofable on some stacks,' . "\n"
        . '// which would send the 256-bit auto-login token to the wrong URL.' . "\n"
        . 'header( "Location: " . home_url( "/wp-login.php" ) . "?sp_token=" . rawurlencode( $sp_token_secret ) );' . "\n"
        . 'exit;' . "\n";

    if ( false === @file_put_contents( $bridge_script, $bridge_code ) ) {
        $bridge_err = error_get_last();
        sp_installer_die(
            'Couldn’t Finish Setting Up',
            'Everything downloaded, but the installer couldn’t write the small script it uses to complete the WordPress setup. This is a file-permissions or disk-space problem in this folder.',
            $bridge_err['message'] ?? '',
            [
                'In cPanel → File Manager, confirm the folder this installer is in is writable (permissions 755).',
                'Confirm you haven’t run out of disk space.',
                'Then click Go Back and try again.',
            ]
        );
    }
    $log[] = 'Bridge script created.';

    // Remove our .htaccess so the bridge script is reachable via HTTP
    $our_htaccess = $install_dir . '/.htaccess';
    $htaccess_bak = $install_dir . '/.htaccess.sp-bak';
    if ( file_exists( $our_htaccess ) ) {
        rename( $our_htaccess, $htaccess_bak );
    }

    // ---- 8. Download and install SocietyPress ----
    // WHY: We extract SocietyPress BEFORE redirecting to the WordPress installer
    // bridge script. The mu-plugin needs the plugin and theme files to already
    // be in place when it fires on first admin page load.
    // WHY: We try our own bundle URL first (hosted on getsocietypress.org) because
    // it's a direct download with no redirects. GitHub's archive URLs require redirect
    // following and sometimes fail on locked-down shared hosts. The bundle contains
    // the plugin + parent theme + all child themes in a simple flat structure.
    $log[] = 'Downloading SocietyPress...';

    $sp_zip_path = $install_dir . '/societypress-latest.zip';
    $sp_downloaded = false;

    // Try 1: Bundle ZIP in the same directory as this installer
    // WHY: The most reliable approach. If the admin uploaded societypress-bundle.zip
    // alongside sp-installer.php, we just use it. No HTTP, no path guessing.
    $local_bundle = $install_dir . '/societypress-bundle.zip';
    if ( file_exists( $local_bundle ) ) {
        $sp_downloaded = copy( $local_bundle, $sp_zip_path );
        if ( $sp_downloaded ) {
            $log[] = 'SocietyPress bundle found alongside installer.';
        }
    }

    // Try 2: Download from our server via HTTP
    if ( ! $sp_downloaded ) {
        $log[] = 'Local copy not found, trying HTTP download...';
        $sp_downloaded = sp_installer_download( SP_INSTALLER_BUNDLE_URL, $sp_zip_path );
    }

    // Try 3: GitHub fallback
    if ( ! $sp_downloaded ) {
        $log[] = 'Bundle download failed, trying GitHub...';
        $gh_url = sp_installer_get_github_release_url();
        if ( ! $gh_url ) {
            $gh_url = 'https://github.com/' . SP_INSTALLER_GITHUB_REPO . '/archive/refs/heads/main.zip';
        }
        $sp_downloaded = sp_installer_download( $gh_url, $sp_zip_path );
    }

    if ( ! $sp_downloaded ) {
        // WHY plain text, not the old inline <a> link: sp_installer_die()
        // escapes the message, so an embedded anchor would render as literal
        // markup anyway. The manual-install path is spelled out in the steps
        // list instead, which is clearer for a non-technical user.
        sp_installer_die(
            'SocietyPress Couldn’t Be Downloaded',
            'WordPress installed successfully, but the installer couldn’t download SocietyPress itself. WordPress is fine — only the SocietyPress download failed.',
            $GLOBALS['sp_installer_last_download_error'] ?? '',
            [
                'Click Go Back and try again — this is often a temporary network problem.',
                'If it keeps failing, install SocietyPress by hand: download the latest release ZIP from github.com/' . SP_INSTALLER_GITHUB_REPO . '/releases',
                'Then in your WordPress admin, go to Plugins → Add New → Upload Plugin, and upload that ZIP.',
            ]
        );
    }
    if ( ! isset( $log[ count( $log ) - 1 ] ) || strpos( $log[ count( $log ) - 1 ], 'copied' ) === false ) {
        $log[] = 'SocietyPress downloaded.';
    }

    // ---- 9. Extract SocietyPress plugin and themes ----
    $log[] = 'Installing SocietyPress plugin and themes...';

    $zip = new ZipArchive();
    $sp_zip_open = $zip->open( $sp_zip_path );
    if ( $sp_zip_open !== true ) {
        @unlink( $sp_zip_path );
        sp_installer_die(
            'The SocietyPress Download Couldn’t Be Opened',
            'WordPress is installed, but the SocietyPress download couldn’t be opened as a ZIP archive — usually a download that was cut off partway.',
            sp_installer_zip_err( $sp_zip_open ),
            [
                'Click Go Back and try again.',
                'If it keeps failing, download SocietyPress from GitHub and upload it through your WordPress admin under Plugins → Add New → Upload Plugin.',
            ]
        );
    }

    // The bundle ZIP has a flat structure:
    //   societypress/societypress.php  → wp-content/plugins/societypress/
    //   themes/societypress/           → wp-content/themes/societypress/
    //   themes/heritage/               → wp-content/themes/heritage/
    //   themes/coastline/              → wp-content/themes/coastline/
    //   themes/prairie/                → wp-content/themes/prairie/
    //   themes/ledger/                 → wp-content/themes/ledger/
    //
    // If we got a GitHub ZIP instead (fallback), it has:
    //   SocietyPress-main/plugin/      → wp-content/plugins/societypress/
    //   SocietyPress-main/theme/       → wp-content/themes/societypress/
    //   SocietyPress-main/theme-NAME/  → wp-content/themes/NAME/

    // Detect which format we got
    $is_bundle = false;
    $top_dir   = '';
    for ( $i = 0; $i < $zip->numFiles; $i++ ) {
        $name = $zip->getNameIndex( $i );
        if ( strpos( $name, 'societypress/societypress.php' ) === 0 ) {
            $is_bundle = true;
            break;
        }
        if ( preg_match( '#^([^/]+)/plugin/#', $name, $m ) ) {
            $top_dir = $m[1];
            break;
        }
    }

    // SECURITY: Both extraction paths build $target by concatenating user-
    // controllable ZIP entry names onto known install paths. A crafted entry
    // like "../../../etc/cron.d/evil" would escape the wp-content directory.
    // This helper validates every write: the entry name must be free of path
    // traversal sequences, and the resolved parent directory must live inside
    // wp-content. We close the zip and abort on any violation rather than
    // silently skip — a corrupt or malicious bundle is not safe to recover from.
    $wp_content_real = realpath( $install_dir . '/wp-content' );
    if ( $wp_content_real === false ) {
        @mkdir( $install_dir . '/wp-content', 0755, true );
        $wp_content_real = realpath( $install_dir . '/wp-content' );
    }
    $sp_safe_extract = function ( string $entry_name, string $target, ZipArchive $zip, int $index ) use ( $wp_content_real, $sp_zip_path ) {
        if ( strpos( $entry_name, "\0" ) !== false
             || strpos( $entry_name, '..' ) !== false
             || ( $entry_name !== '' && $entry_name[0] === '/' )
             || preg_match( '/^[A-Za-z]:/', $entry_name ) ) {
            $zip->close();
            @unlink( $sp_zip_path );
            sp_installer_die( 'Extract Failed', 'SocietyPress archive contains an unsafe entry path. Aborting for safety.' );
        }
        $is_dir = ( substr( $entry_name, -1 ) === '/' );
        $parent = $is_dir ? $target : dirname( $target );
        if ( ! is_dir( $parent ) ) {
            @mkdir( $parent, 0755, true );
        }
        $parent_real = realpath( $parent );
        // WHY trailing separator: bare prefix match would let
        // '/wp-contentevil' pass as inside '/wp-content'. Force boundary.
        $sp_needle    = rtrim( (string) $wp_content_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
        $sp_haystack  = rtrim( (string) $parent_real,     DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
        if ( $parent_real === false || $wp_content_real === false || strpos( $sp_haystack, $sp_needle ) !== 0 ) {
            $zip->close();
            @unlink( $sp_zip_path );
            sp_installer_die( 'Extract Failed', 'SocietyPress archive entry resolved outside wp-content. Aborting for safety.' );
        }
        if ( ! $is_dir ) {
            // WHY stream instead of getFromIndex(): getFromIndex() buffers the
            // entire decompressed entry in a PHP string before writing. On a
            // 64 MB shared host that peak allocation can OOM mid-extraction and
            // leave a partial plugin directory. Streaming keeps memory flat,
            // matching the WordPress extraction path above.
            $entry_stream = $zip->getStreamIndex( $index );
            if ( $entry_stream !== false ) {
                $out = fopen( $target, 'wb' );
                if ( $out !== false ) {
                    stream_copy_to_stream( $entry_stream, $out );
                    fclose( $out );
                }
                if ( is_resource( $entry_stream ) ) {
                    fclose( $entry_stream );
                }
            }
        }
    };

    if ( $is_bundle ) {
        // Bundle format: extract directly
        for ( $i = 0; $i < $zip->numFiles; $i++ ) {
            $name = $zip->getNameIndex( $i );

            // Plugin: societypress/* → plugins/societypress/*
            if ( strpos( $name, 'societypress/' ) === 0 && strpos( $name, 'themes/' ) !== 0 ) {
                $target = $install_dir . '/wp-content' . '/plugins/' . $name;
                $sp_safe_extract( $name, $target, $zip, $i );
            }

            // Themes: themes/* → themes/*
            if ( strpos( $name, 'themes/' ) === 0 ) {
                $target = $install_dir . '/wp-content' . '/' . $name;
                $sp_safe_extract( $name, $target, $zip, $i );
            }
        }
    } elseif ( $top_dir ) {
        // GitHub format: extract with path rewriting
        $plugin_dir = $install_dir . '/wp-content' . '/plugins/societypress/';
        @mkdir( $plugin_dir, 0755, true );

        $child_themes = [ 'heritage', 'coastline', 'prairie', 'ledger', 'parlor' ];

        for ( $i = 0; $i < $zip->numFiles; $i++ ) {
            $name = $zip->getNameIndex( $i );

            // Plugin
            if ( strpos( $name, "{$top_dir}/plugin/" ) === 0 ) {
                $rel = substr( $name, strlen( "{$top_dir}/plugin/" ) );
                if ( $rel === '' ) continue;
                $sp_safe_extract( $rel, $plugin_dir . $rel, $zip, $i );
            }

            // Parent theme
            if ( strpos( $name, "{$top_dir}/theme/" ) === 0 && strpos( $name, "{$top_dir}/theme-" ) !== 0 ) {
                $rel = substr( $name, strlen( "{$top_dir}/theme/" ) );
                if ( $rel === '' ) continue;
                $sp_safe_extract( $rel, $install_dir . '/wp-content' . '/themes/societypress/' . $rel, $zip, $i );
            }

            // Child themes
            foreach ( $child_themes as $ct ) {
                if ( strpos( $name, "{$top_dir}/theme-{$ct}/" ) === 0 ) {
                    $rel = substr( $name, strlen( "{$top_dir}/theme-{$ct}/" ) );
                    if ( $rel === '' ) continue;
                    $sp_safe_extract( $rel, $install_dir . '/wp-content' . "/themes/{$ct}/" . $rel, $zip, $i );
                }
            }
        }
    } else {
        $zip->close();
        @unlink( $sp_zip_path );
        sp_installer_die( 'Extract Failed', 'Could not find SocietyPress in the downloaded archive.' );
    }

    $zip->close();
    @unlink( $sp_zip_path );
    $log[] = 'SocietyPress plugin and themes installed.';

    // ---- 10. Redirect to the bridge script to finish WordPress install ----
    // WHY: Server-to-self HTTP requests fail on this host. We redirect the user's
    // browser to the bridge script, which loads WordPress in a normal HTTP context,
    // runs wp_install(), sets up permalinks, self-destructs, and redirects to login.
    // The mu-plugin (planted in step 7) activates SocietyPress on first admin load.
    $log[] = 'Redirecting to WordPress installation...';
    $_SESSION['sp_install_log'] = $log;

    // WHY: Hand the admin password to the bridge script via the server-side
    // session instead of embedding it in the generated PHP file. The browser
    // carries the session cookie to the bridge on the redirect below; the
    // bridge reads and unsets it. This keeps the plaintext password off disk.
    $_SESSION['sp_bridge_pass'] = $admin_pass;

    // WHY a bare relative Location: the bridge script is a sibling of this
    // installer in the same directory, so a relative target ("sp-bridge-
    // install.php") is unambiguous to the browser and valid per RFC 7231.
    // This avoids depending on $_SERVER['SCRIPT_NAME'], which can be wrong on
    // Nginx/cPanel and is influenceable on some proxy/CGI setups — a path we
    // don't want the install-bridge token riding on.
    // The token is appended so only this redirect can trigger the bridge script.
    header( 'Location: sp-bridge-install.php?token=' . urlencode( $bridge_token ) );
    exit;
}


// ============================================================================
// STEP 4: COMPLETION PAGE
// ============================================================================

/**
 * Show the success page after installation.
 */
function sp_installer_show_complete(): void {
    $admin_url = $_SESSION['sp_admin_url'] ?? '/wp-admin/';
    $site_url  = $_SESSION['sp_site_url'] ?? '/';
    $log       = $_SESSION['sp_install_log'] ?? [];

    // WHY a scheme guard: htmlspecialchars stops HTML injection but not a
    // javascript: URL used as an href. esc_url() isn't available here (WordPress
    // isn't loaded in the bare installer), so reject anything that isn't a
    // root-relative path or an http/https URL and fall back to safe defaults.
    $sp_safe_href = static function ( string $url, string $fallback ): string {
        if ( $url === '' ) {
            return $fallback;
        }
        if ( $url[0] === '/' && ( ! isset( $url[1] ) || $url[1] !== '/' ) ) {
            return $url; // root-relative path, e.g. /wp-admin/
        }
        if ( preg_match( '#^https?://#i', $url ) ) {
            return $url;
        }
        return $fallback;
    };
    $admin_url = $sp_safe_href( (string) $admin_url, '/wp-admin/' );
    $site_url  = $sp_safe_href( (string) $site_url, '/' );

    // Clear session data
    unset( $_SESSION['sp_install_complete'], $_SESSION['sp_install_log'],
           $_SESSION['sp_admin_url'], $_SESSION['sp_site_url'] );

    sp_installer_render_page( 'Installation Complete!', function () use ( $admin_url, $site_url, $log ) {
        ?>
        <div style="text-align: center; padding: 20px 0;">
            <div style="font-size: 64px; margin-bottom: 16px;">&#127881;</div>
            <h2 style="font-size: 24px; color: #0D1F3C; margin-bottom: 8px;">SocietyPress is ready!</h2>
            <p style="color: #6B7280; margin-bottom: 32px;">
                Your site is installed and configured. The SocietyPress setup wizard will guide you
                through the rest — your organization details, membership settings, and design choices.
            </p>

            <a href="<?php echo htmlspecialchars( $admin_url , ENT_QUOTES, 'UTF-8'); ?>" class="sp-btn">
                Go to Your Dashboard &rarr;
            </a>

            <p style="margin-top: 16px;">
                <a href="<?php echo htmlspecialchars( $site_url , ENT_QUOTES, 'UTF-8'); ?>" style="color: #C9973A; text-decoration: none;">
                    or visit your new site
                </a>
            </p>
        </div>

        <?php if ( $log ) : ?>
            <details style="margin-top: 32px; padding: 16px; background: #F9FAFB; border-radius: 8px;">
                <summary style="cursor: pointer; font-weight: 600; color: #6B7280;">Installation Log</summary>
                <pre style="margin-top: 8px; font-size: 12px; color: #374151; white-space: pre-wrap;"><?php
                    echo htmlspecialchars( implode( "\n", $log ) , ENT_QUOTES, 'UTF-8');
                ?></pre>
            </details>
        <?php endif; ?>
        <?php
    } );
}


// ============================================================================
// HELPER FUNCTIONS
// ============================================================================

/**
 * Download a file from a URL to a local path.
 * Tries cURL first, falls back to file_get_contents.
 */
function sp_installer_download( string $url, string $dest ): bool {
    // WHY a global last-error string: this function returns only true/false, but
    // its callers want to tell the user *why* a download failed (a connection
    // error, an HTTP 403/404, a host with neither cURL nor allow_url_fopen). We
    // stash the reason here and the caller reads it into sp_installer_die()'s
    // technical-details box. A global keeps the bool return signature intact.
    $GLOBALS['sp_installer_last_download_error'] = '';
    // WHY a hard size cap: WordPress core is ~25 MB and the SocietyPress
    // bundle is ~9 MB. A compromised CDN or bad mirror serving an unbounded
    // response could exhaust disk space or PHP memory before the timeout
    // fires. 200 MB is well above any legitimate payload while still bounded.
    $max_bytes = 200 * 1024 * 1024;

    // Try cURL first
    if ( function_exists( 'curl_init' ) ) {
        $ch = curl_init( $url );
        $fp = fopen( $dest, 'w' );
        if ( ! $fp ) {
            $GLOBALS['sp_installer_last_download_error'] = 'Could not open a local file to save the download — check this folder’s permissions and that the disk isn’t full.';
            return false;
        }

        curl_setopt_array( $ch, [
            CURLOPT_FILE            => $fp,
            CURLOPT_FOLLOWLOCATION  => true,
            CURLOPT_MAXREDIRS       => 5,
            CURLOPT_TIMEOUT         => 300,
            CURLOPT_SSL_VERIFYPEER  => true,
            CURLOPT_USERAGENT       => 'SocietyPress-Installer/1.0',
            // CURLOPT_MAXFILESIZE checks the Content-Length header (when
            // present) up front and aborts early. CURLOPT_PROGRESSFUNCTION
            // catches servers that omit Content-Length by aborting once the
            // running total crosses the cap.
            CURLOPT_MAXFILESIZE     => $max_bytes,
            CURLOPT_NOPROGRESS      => false,
            CURLOPT_PROGRESSFUNCTION => function ( $resource, $dl_total, $dl_now, $ul_total, $ul_now ) use ( $max_bytes ) {
                return ( $dl_now > $max_bytes ) ? 1 : 0; // non-zero return aborts
            },
        ] );

        $success  = curl_exec( $ch );
        $code     = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
        $curl_err = curl_error( $ch );
        curl_close( $ch );
        fclose( $fp );

        $size = @filesize( $dest );
        if ( $success && $code >= 200 && $code < 400 && $size > 1000 && $size <= $max_bytes ) {
            return true;
        }
        // Record why cURL failed before we fall through to the fopen attempt.
        // A real connection error (DNS, TLS, timeout) is the most useful thing
        // to report; otherwise the HTTP status and byte count point at a server
        // returning an error page or an empty body.
        if ( $curl_err !== '' ) {
            $GLOBALS['sp_installer_last_download_error'] = 'Connection error: ' . $curl_err;
        } else {
            $GLOBALS['sp_installer_last_download_error'] = 'The server responded with HTTP ' . (int) $code
                . ( $size !== false ? ', ' . (int) $size . ' bytes received' : '' ) . '.';
        }
        @unlink( $dest );
    }

    // Fallback to file_get_contents
    if ( ini_get( 'allow_url_fopen' ) ) {
        // WHY explicit ssl context: PHP stream wrappers don't enable peer
        // verification by default unless openssl.cafile / curl.cainfo is
        // set in php.ini, which most shared hosts don't. Force-on here so
        // a MITM can't slip a crafted bundle past us via the fallback path.
        $ctx = stream_context_create( [
            'http' => [
                'timeout'    => 300,
                'user_agent' => 'SocietyPress-Installer/1.0',
                'follow_location' => true,
            ],
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ] );
        $data = @file_get_contents( $url, false, $ctx, 0, $max_bytes + 1 );
        if ( $data && strlen( $data ) > 1000 && strlen( $data ) <= $max_bytes ) {
            return (bool) file_put_contents( $dest, $data );
        }
        // Only overwrite the cURL reason if cURL wasn't even available — when
        // both transports ran, the cURL error is the more specific one to keep.
        if ( $GLOBALS['sp_installer_last_download_error'] === '' ) {
            $fopen_err = error_get_last();
            $GLOBALS['sp_installer_last_download_error'] = $fopen_err['message'] ?? 'The download could not be retrieved.';
        }
    } elseif ( ! function_exists( 'curl_init' ) ) {
        $GLOBALS['sp_installer_last_download_error'] = 'This server has neither cURL nor allow_url_fopen enabled, so it can’t download files. Ask your host to enable one of them.';
    }

    return false;
}

/**
 * Download a URL and return the content as a string.
 */
function sp_installer_download_string( string $url ): ?string {
    // WHY a 1 MB cap: the only callers are the WordPress salt API (~1 KB) and
    // the GitHub releases API (< 100 KB). A compromised or misbehaving endpoint
    // returning a multi-megabyte body would otherwise grow unbounded in memory
    // until the limit or timeout fires. 1 MB is far above any legitimate size.
    $max_bytes = 1024 * 1024;

    if ( function_exists( 'curl_init' ) ) {
        $ch = curl_init( $url );
        curl_setopt_array( $ch, [
            CURLOPT_RETURNTRANSFER  => true,
            CURLOPT_FOLLOWLOCATION  => true,
            CURLOPT_MAXREDIRS       => 5,
            CURLOPT_TIMEOUT         => 30,
            CURLOPT_SSL_VERIFYPEER  => true,
            CURLOPT_USERAGENT       => 'SocietyPress-Installer/1.0',
            CURLOPT_MAXFILESIZE     => $max_bytes,
        ] );
        $result = curl_exec( $ch );
        curl_close( $ch );
        if ( $result ) return $result;
    }

    if ( ini_get( 'allow_url_fopen' ) ) {
        // WHY: same as above — explicit peer verification on the stream
        // fallback so we don't quietly skip TLS validation when shared-host
        // php.ini doesn't set openssl.cafile.
        $ctx = stream_context_create( [
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ] );
        $result = @file_get_contents( $url, false, $ctx, 0, $max_bytes );
        if ( $result ) return $result;
    }

    return null;
}

/**
 * POST to a URL and return the response body as a string.
 * Used to submit the WordPress install form programmatically.
 */
function sp_installer_download_string_post( string $url, array $data ): ?string {
    if ( function_exists( 'curl_init' ) ) {
        $ch = curl_init( $url );
        curl_setopt_array( $ch, [
            CURLOPT_RETURNTRANSFER  => true,
            CURLOPT_FOLLOWLOCATION  => true,
            CURLOPT_POST            => true,
            CURLOPT_POSTFIELDS      => http_build_query( $data ),
            CURLOPT_TIMEOUT         => 60,
            CURLOPT_SSL_VERIFYPEER  => true,
            CURLOPT_USERAGENT       => 'SocietyPress-Installer/1.0',
        ] );
        $result = curl_exec( $ch );
        curl_close( $ch );
        if ( $result ) return $result;
    }

    if ( ini_get( 'allow_url_fopen' ) ) {
        $ctx = stream_context_create( [
            'http' => [
                'method'  => 'POST',
                'header'  => 'Content-Type: application/x-www-form-urlencoded',
                'content' => http_build_query( $data ),
                'timeout' => 60,
            ],
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ] );
        $result = @file_get_contents( $url, false, $ctx );
        if ( $result ) return $result;
    }

    return null;
}

/**
 * Get the latest release ZIP URL from GitHub.
 */
function sp_installer_get_github_release_url(): ?string {
    $api_url = 'https://api.github.com/repos/' . SP_INSTALLER_GITHUB_REPO . '/releases/latest';
    $json = sp_installer_download_string( $api_url );
    if ( ! $json ) return null;

    $data = json_decode( $json, true );
    return $data['zipball_url'] ?? null;
}

/**
 * Recursively remove a directory.
 */
function sp_installer_rmdir( string $dir ): void {
    if ( ! is_dir( $dir ) ) return;
    $items = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ( $items as $item ) {
        if ( $item->isDir() ) {
            @rmdir( $item->getPathname() );
        } else {
            @unlink( $item->getPathname() );
        }
    }
    @rmdir( $dir );
}

/**
 * Show an error page and stop.
 *
 * @param string   $title   The page title / headline.
 * @param string   $message The plain-English explanation of what went wrong.
 * @param string   $detail  Optional raw technical detail (a PHP/MySQL/ZIP error
 *                          string). Shown inside a collapsible "Technical
 *                          details" box so a non-technical user can ignore it
 *                          but still read it to their host's support line.
 * @param string[] $steps   Optional numbered list of concrete things to try.
 *
 * WHY the extra params are optional: dozens of existing callers pass only a
 * title and message. Defaulting $detail and $steps keeps every one of those
 * working untouched while letting the failure paths that *do* know more (a
 * MySQL error, a filesystem error, an HTTP status) surface it.
 */
function sp_installer_die( string $title, string $message, string $detail = '', array $steps = [] ): void {
    // WHY: If we already renamed .htaccess to .htaccess.sp-bak (so the bridge
    // script could be reached over HTTP) and then hit a fatal error before the
    // bridge ran its own cleanup, the site is left with no rewrite rules and
    // every URL 404s on Apache. Restore the backup before showing the error.
    $htaccess_bak = __DIR__ . '/.htaccess.sp-bak';
    if ( file_exists( $htaccess_bak ) && ! file_exists( __DIR__ . '/.htaccess' ) ) {
        @rename( $htaccess_bak, __DIR__ . '/.htaccess' );
    }

    sp_installer_render_page( $title, function () use ( $message, $detail, $steps ) {
        echo '<div style="background: #FEF2F2; border: 1px solid #FECACA; border-radius: 8px; padding: 20px; margin-bottom: 24px;">';
        // WHY htmlspecialchars: most callers pass static literals, but a few
        // include dynamic content (like raw mysqli error messages, server
        // version strings, or paths). Escaping here means no caller can
        // accidentally inject unescaped output into the page.
        echo '<p style="color: #991B1B; margin: 0;">' . htmlspecialchars( $message, ENT_QUOTES, 'UTF-8' ) . '</p>';
        echo '</div>';
        sp_installer_render_steps( $steps );
        sp_installer_render_detail( $detail );
        echo '<a href="javascript:history.back()" class="sp-btn" style="background: #6B7280;">Go Back</a>';
    } );
    exit;
}

/**
 * Render an ordered "things to try" list, or nothing if the list is empty.
 *
 * WHY a shared helper: both the generic error page and the dedicated database
 * error page show numbered, non-technical fix steps. Keeping the markup in one
 * place keeps them visually identical and keeps escaping consistent.
 */
function sp_installer_render_steps( array $steps ): void {
    if ( ! $steps ) {
        return;
    }
    echo '<p style="font-weight: 600; color: #0D1F3C; margin: 0 0 8px;">What to try:</p>';
    echo '<ol style="margin: 0 0 24px; padding-left: 22px; color: #374151;">';
    foreach ( $steps as $step ) {
        echo '<li style="margin-bottom: 8px;">' . htmlspecialchars( (string) $step, ENT_QUOTES, 'UTF-8' ) . '</li>';
    }
    echo '</ol>';
}

/**
 * Render a collapsed "Technical details" box, or nothing if there's no detail.
 *
 * WHY collapsed: Harold should never have to read a raw MySQL or PHP error to
 * fix his install — the plain-English steps above cover that. But when he has
 * to phone his host's support, the raw string is exactly what they ask for. A
 * <details> element keeps it out of the way until it's wanted.
 */
function sp_installer_render_detail( string $detail ): void {
    $detail = trim( $detail );
    if ( $detail === '' ) {
        return;
    }
    echo '<details style="margin-bottom: 24px; background: #F9FAFB; border: 1px solid #E5E7EB; border-radius: 8px; padding: 12px 16px;">';
    echo '<summary style="cursor: pointer; font-weight: 600; color: #6B7280; font-size: 13px;">Technical details (for your hosting provider)</summary>';
    echo '<pre style="margin: 8px 0 0; font-size: 12px; color: #374151; white-space: pre-wrap; word-break: break-word;">'
        . htmlspecialchars( $detail, ENT_QUOTES, 'UTF-8' ) . '</pre>';
    echo '</details>';
}

/**
 * Render a detailed, plain-English database-connection error page and stop.
 *
 * WHY this exists as its own function: the database step is the single most
 * common place a non-technical user's install dies, and on shared hosting
 * (shared cPanel hosts above all) the cause is almost never "wrong password." It's the
 * user-not-added-to-database step, or the cPanel name prefix, or the database
 * simply not having been created yet. The old code reduced all of these to one
 * useless "check your credentials" line. This reads the MySQL error number and
 * gives the ONE matching fix, in cPanel terms, with the raw error tucked away
 * for support calls.
 */
function sp_installer_db_error_page( int $errno, string $raw, string $db_host, string $db_name, string $db_user ): void {
    // MySQL connection error numbers we recognize:
    //   1045 — access denied (bad username or password)
    //   1044 / 1142 — access denied to THIS database (user exists, not granted)
    //   1049 — unknown database (name wrong, or not created yet)
    //   2002 — can't connect locally (host wrong, or MySQL not running)
    //   2003 — can't connect to host:port (remote host wrong/unreachable)
    //   2005 — unknown host (the database host name doesn't resolve)
    // The cPanel name-prefix tip shows on the credential/name errors because
    // many cPanel hosts force a "cpanelusername_" prefix on both the database and the
    // user, and leaving it off is the usual root cause of 1045/1049.
    $explain     = '';
    $steps       = [];
    $prefix_hint = false;

    switch ( $errno ) {
        case 1045:
            // WHY we don't claim the database exists here: 1045 is rejected at
            // the login stage, before MySQL ever selects the database, so we
            // genuinely don't know whether the database is present. We also
            // can't confirm the username is valid — MySQL returns the same 1045
            // whether the password is wrong OR the user doesn't exist. So we
            // state only what's actually proven: the server is reachable and the
            // login was refused.
            $explain = 'What we know: your database server is running and the installer reached it — but it wouldn’t accept this username and password together. Nine times out of ten that’s the password. The very same error also appears when the username doesn’t actually exist yet, so it’s worth checking both.';
            $steps = [
                'Double-check the password — it’s case-sensitive, and a stray space or a mis-pasted special character is the usual cause. The surest fix is to reset it in cPanel → MySQL Databases → "Current Users" → "Change Password" to something simple, then enter that exact password here.',
                'Confirm the user actually saved — in cPanel → MySQL Databases, look under "Current Users" for the exact name you entered. cPanel sometimes silently drops a new user whose password failed its strength meter, so it looks created but isn’t.',
                'Make sure you’re using the database user’s password — not your cPanel login or your WordPress password.',
            ];
            $prefix_hint = true;
            break;
        case 1044:
        case 1142:
            $explain = 'Good news: your username and password are correct and the database exists. The user just hasn’t been given permission to use this particular database yet. On most cPanel hosts, creating the database and creating the user are two separate steps — and a third step links them together. That third step is missing.';
            $steps = [
                'Open cPanel → MySQL Databases.',
                'Scroll to "Add User To Database."',
                'Choose your user and your database in the two drop down menus, then click "Add."',
                'On the next screen, check "ALL PRIVILEGES," then click "Make Changes."',
                'Come back here and click Go Back, then try the install again.',
            ];
            break;
        case 1049:
            $explain = 'WordPress connected to your database server, but there’s no database with the name you entered. Either the name is slightly off, or the database hasn’t been created yet.';
            $steps = [
                'Open cPanel → MySQL Databases (or the "MySQL Database Wizard").',
                'Under "Create New Database," enter a name and click "Create Database."',
                'Add your user to it under "Add User To Database" with ALL PRIVILEGES.',
                'Copy the full database name exactly as cPanel shows it and paste it back here.',
            ];
            $prefix_hint = true;
            break;
        case 2002:
            $explain = 'WordPress tried to reach the database at the host you entered and got no answer. On shared hosting the host is almost always "localhost."';
            $steps = [
                'Set the Database Host field to "localhost" unless your host specifically told you otherwise.',
                'A few hosts use a named database server instead — check your host’s welcome email for the MySQL host name.',
                'If you just created the database, wait a minute and try again — some hosts take a moment to provision it.',
            ];
            break;
        case 2003:
            $explain = 'The database host you entered didn’t accept a connection. The host name or port may be wrong, or that server may not allow connections from this site.';
            $steps = [
                'On shared hosting, set the Database Host to "localhost."',
                'If your host gave you a specific database server address, enter it exactly as provided.',
            ];
            break;
        case 2005:
            $explain = 'The Database Host you entered couldn’t be found. It may be misspelled.';
            $steps = [
                'On shared hosting, the host is almost always "localhost."',
                'If your host gave you a named database server, check it carefully for typos.',
            ];
            break;
        default:
            $explain = 'WordPress couldn’t connect to your database. The exact reason from the database server is in "Technical details" below — that text is the most useful thing to give your hosting provider if you need to call them.';
            $steps = [
                'Re-check the database name, username, password, and host against what your host gave you.',
                'Make sure the database and user both exist, and that the user has been added to the database with full privileges.',
            ];
            $prefix_hint = true;
            break;
    }

    // Restore .htaccess if a prior step renamed it (defensive — the DB step
    // runs before that rename, but sp_installer_die does the same guard and
    // costs nothing here).
    $htaccess_bak = __DIR__ . '/.htaccess.sp-bak';
    if ( file_exists( $htaccess_bak ) && ! file_exists( __DIR__ . '/.htaccess' ) ) {
        @rename( $htaccess_bak, __DIR__ . '/.htaccess' );
    }

    // Build the raw technical detail string from what we know, even if the
    // server gave us no message text (some hosts return only a number).
    $detail = 'MySQL error ' . $errno;
    if ( trim( $raw ) !== '' ) {
        $detail .= ': ' . $raw;
    }
    $detail .= "\nDatabase: " . $db_name . "\nUser: " . $db_user . "\nHost: " . $db_host;

    sp_installer_render_page( 'Couldn’t Connect to the Database', function () use ( $explain, $steps, $prefix_hint, $detail, $db_name, $db_user ) {
        echo '<div style="background: #FEF2F2; border: 1px solid #FECACA; border-radius: 8px; padding: 20px; margin-bottom: 24px;">';
        echo '<p style="color: #991B1B; margin: 0;">' . htmlspecialchars( $explain, ENT_QUOTES, 'UTF-8' ) . '</p>';
        echo '</div>';

        if ( $prefix_hint ) {
            // WHY a neutral example, not a real-looking account name: this text
            // ships in the public repo and on the demo site. "yourname_" makes
            // the pattern obvious without resembling any real cPanel username.
            echo '<div style="background: #FFFBEB; border: 1px solid #FDE68A; border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; font-size: 13px; color: #92400E;">';
            echo '<strong>cPanel tip:</strong> Many cPanel hosts automatically put your cPanel username and an underscore in front of <em>both</em> the database name and the database username — like <code>yourname_society</code> and <code>yourname_dbuser</code>. Make sure you included that prefix here, exactly as cPanel shows it.';
            echo '</div>';
        }

        sp_installer_render_steps( $steps );
        sp_installer_render_detail( $detail );

        echo '<a href="?step=configure" class="sp-btn" style="background: #6B7280;">Go Back and Fix It</a>';
        echo '<p style="color: #9CA3AF; font-size: 12px; margin-top: 12px;">Your other answers are saved — you only need to correct the database fields.</p>';
    } );
    exit;
}

/**
 * Translate a ZipArchive::open() failure code into a readable reason.
 *
 * WHY: ZipArchive::open() returns true on success but an opaque integer on
 * failure. "ZIP error code 19" means nothing to anyone; "Not a zip archive —
 * the download was probably incomplete" points straight at the real cause,
 * which on shared hosts is almost always a truncated or error-page download.
 */
function sp_installer_zip_err( $code ): string {
    if ( $code === true ) {
        return '';
    }
    $map = [
        ZipArchive::ER_EXISTS => 'File already exists',
        ZipArchive::ER_INCONS => 'Zip archive is inconsistent (likely a corrupt or partial download)',
        ZipArchive::ER_INVAL  => 'Invalid argument',
        ZipArchive::ER_MEMORY => 'The server ran out of memory opening the archive',
        ZipArchive::ER_NOENT  => 'The file was not found',
        ZipArchive::ER_NOZIP  => 'Not a zip archive — the download was probably incomplete, or the host returned an error page instead of the file',
        ZipArchive::ER_OPEN   => 'The file could not be opened (check folder permissions)',
        ZipArchive::ER_READ   => 'Read error while opening the archive',
        ZipArchive::ER_SEEK   => 'Seek error while opening the archive',
    ];
    $reason = $map[ $code ] ?? 'Unknown ZIP error';
    return 'ZIP error code ' . (int) $code . ': ' . $reason;
}

/**
 * Render a full HTML page with SocietyPress branding.
 *
 * WHY: Every page the installer shows should look professional and on-brand.
 * This is Harold's first impression of SocietyPress — it needs to feel polished
 * and trustworthy, not like a raw PHP script.
 */
function sp_installer_render_page( string $title, callable $content ): void {
    ?>
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title><?php echo htmlspecialchars( $title , ENT_QUOTES, 'UTF-8'); ?> — SocietyPress Installer</title>
        <style>
            @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');

            * { margin: 0; padding: 0; box-sizing: border-box; }

            body {
                font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
                background: #f0f2f5;
                color: #1A1A1A;
                line-height: 1.6;
                min-height: 100vh;
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 40px 20px;
            }

            .sp-installer {
                background: #fff;
                border-radius: 16px;
                box-shadow: 0 4px 24px rgba(0,0,0,0.08);
                max-width: 680px;
                width: 100%;
                overflow: hidden;
            }

            .sp-installer-header {
                background: #0D1F3C;
                color: #fff;
                padding: 24px 32px;
                text-align: center;
            }

            .sp-installer-header h1 {
                font-size: 14px;
                font-weight: 400;
                letter-spacing: 2px;
                text-transform: uppercase;
                color: #C9973A;
                margin-bottom: 4px;
            }

            .sp-installer-header .step-title {
                font-size: 22px;
                font-weight: 700;
            }

            .sp-installer-body {
                padding: 32px;
            }

            .sp-btn {
                display: inline-block;
                background: #C9973A;
                color: #fff;
                border: none;
                padding: 14px 32px;
                font-size: 15px;
                font-weight: 600;
                border-radius: 8px;
                cursor: pointer;
                text-decoration: none;
                transition: background 0.2s;
            }
            .sp-btn:hover { background: #B8862F; }

            .sp-form-table {
                width: 100%;
                border-collapse: collapse;
            }
            .sp-form-table tr {
                border-bottom: 1px solid #F3F4F6;
            }
            .sp-form-table th {
                text-align: left;
                padding: 14px 16px 14px 0;
                font-weight: 600;
                font-size: 14px;
                color: #374151;
                width: 160px;
                vertical-align: top;
                padding-top: 18px;
            }
            .sp-form-table td {
                padding: 12px 0;
            }
            .sp-form-table input[type="text"],
            .sp-form-table input[type="email"],
            .sp-form-table input[type="password"] {
                width: 100%;
                padding: 10px 14px;
                border: 1px solid #D1D5DB;
                border-radius: 6px;
                font-size: 14px;
                font-family: inherit;
                transition: border-color 0.2s;
            }
            .sp-form-table input:focus {
                outline: none;
                border-color: #C9973A;
                box-shadow: 0 0 0 3px rgba(201, 151, 58, 0.1);
            }
            .sp-form-table .desc {
                font-size: 12px;
                color: #9CA3AF;
                margin-top: 4px;
            }

            @media (max-width: 600px) {
                .sp-form-table th {
                    display: block;
                    width: 100%;
                    padding-bottom: 4px;
                }
                .sp-form-table td {
                    display: block;
                    padding-top: 0;
                }
                .sp-installer-body { padding: 20px; }
            }
        </style>
    </head>
    <body>
        <div class="sp-installer">
            <div class="sp-installer-header">
                <h1>SocietyPress</h1>
                <div class="step-title"><?php echo htmlspecialchars( $title , ENT_QUOTES, 'UTF-8'); ?></div>
            </div>
            <div class="sp-installer-body">
                <?php $content(); ?>
            </div>
        </div>
    </body>
    </html>
    <?php
}
