<?php

declare(strict_types=1);

/**
 * XSPA 安裝精靈：偵測未安裝狀態、匯入 schema、建立首位管理員。
 */

require_once __DIR__ . DIRECTORY_SEPARATOR . 'admin_member_auth.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_db_connect.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_admin_members.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_member_password.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_member_register.php';

/** 站台在網址上的路徑前綴（不含尾端斜線），例如 '' 或 '/fate'。 */
function fate_install_site_web_prefix(): string
{
    static $cached = null;
    if (is_string($cached)) {
        return $cached;
    }

    $fateRoot = dirname(__DIR__);
    $docRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
    if (is_string($docRoot) && $docRoot !== '') {
        $docReal = realpath($docRoot);
        $fateReal = realpath($fateRoot);
        if ($docReal !== false && $fateReal !== false) {
            $docNorm = rtrim(str_replace('\\', '/', $docReal), '/');
            $fateNorm = str_replace('\\', '/', $fateReal);
            if ($fateNorm === $docNorm || str_starts_with($fateNorm, $docNorm . '/')) {
                $rel = substr($fateNorm, strlen($docNorm));
                $cached = ($rel === '' || $rel === false) ? '' : rtrim($rel, '/');

                return $cached;
            }
        }
    }

    $cached = fate_install_site_web_prefix_from_script();

    return $cached;
}

function fate_install_site_web_prefix_from_script(): string
{
    $script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? '/'));

    if (preg_match('#^(.*)/install/index\.php$#', $script, $m)) {
        return rtrim((string) $m[1], '/');
    }
    if (preg_match('#^(.*)/admin/.+#', $script, $m)) {
        return rtrim((string) $m[1], '/');
    }

    $dir = str_replace('\\', '/', dirname($script));
    if ($dir === '' || $dir === '.' || $dir === '/') {
        return '';
    }

    return rtrim($dir, '/');
}

/** 站台根目錄下的路徑（以 / 開頭，不含網域）。 */
function fate_install_web_path(string $rel = ''): string
{
    $prefix = fate_install_site_web_prefix();
    $path = ($prefix === '' ? '' : $prefix) . '/' . ltrim(str_replace('\\', '/', $rel), '/');
    if ($path === '' || $path[0] !== '/') {
        $path = '/' . ltrim($path, '/');
    }

    return $path;
}

function fate_install_to_absolute(string $pathOrUrl): string
{
    if ($pathOrUrl === '') {
        return '';
    }
    if (preg_match('#^https?://#i', $pathOrUrl) === 1) {
        return $pathOrUrl;
    }

    $path = $pathOrUrl[0] === '/' ? $pathOrUrl : '/' . $pathOrUrl;

    $env = trim((string) (getenv('FATE_PUBLIC_SITE_ORIGIN') ?: ''));
    if ($env !== '' && preg_match('#^https?://[^\s]+$#', $env) === 1) {
        return rtrim($env, '/') . $path;
    }

    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    $host = trim((string) ($_SERVER['HTTP_HOST'] ?? ''));
    if ($host === '') {
        return $path;
    }

    return $scheme . '://' . $host . $path;
}

function fate_install_is_install_request(): bool
{
    $script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));

    return str_contains($script, '/install/');
}

/** 安裝完成後是否允許透過瀏覽器開啟 install/（預設關閉；設 FATE_INSTALL_ALLOW_WEB=1 可重開）。 */
function fate_install_web_access_allowed(array $probe): bool
{
    if (getenv('FATE_INSTALL_ALLOW_WEB') === '1') {
        return true;
    }

    return empty($probe['complete']);
}

/** 已安裝站點阻擋 install/ 精靈（開源／上線後降低路徑與環境資訊暴露）。 */
function fate_install_enforce_web_access_policy(array $probe): void
{
    if (fate_install_web_access_allowed($probe)) {
        return;
    }
    http_response_code(403);
    header('Content-Type: text/html; charset=UTF-8');
    echo '<!DOCTYPE html><html lang="zh-Hant"><head><meta charset="UTF-8"><title>403 Forbidden</title></head><body>';
    echo '<p>安裝精靈已關閉。若需在本機重新執行，請設定環境變數 <code>FATE_INSTALL_ALLOW_WEB=1</code> 後再開啟 <code>install/</code>。</p>';
    echo '</body></html>';
    exit;
}

/** 安裝精靈完整 URL（含 scheme + host，供 Location 導向）。 */
function fate_install_url(): string
{
    return fate_install_to_absolute(fate_install_web_path('install/'));
}

/** 後台完整 URL（含 scheme + host）。 */
function fate_install_admin_url(): string
{
    return fate_install_to_absolute(fate_install_web_path('admin/'));
}

function fate_install_sql_path(): string
{
    $schema = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR . 'install.schema.sql';
    if (is_file($schema)) {
        return $schema;
    }

    return dirname(__DIR__) . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR . 'install.sql';
}

/** 正規化 SQL 檔案編碼為 UTF-8 文字。 */
function fate_install_normalize_sql_bytes(string $raw): string
{
    if ($raw === '') {
        return '';
    }

    if (strlen($raw) >= 3 && str_starts_with($raw, "\xEF\xBB\xBF")) {
        $raw = substr($raw, 3);
    } elseif (strlen($raw) >= 2 && str_starts_with($raw, "\xFF\xFE")) {
        $converted = @mb_convert_encoding(substr($raw, 2), 'UTF-8', 'UTF-16LE');
        if (is_string($converted) && $converted !== '') {
            $raw = $converted;
        }
    } elseif (strlen($raw) >= 2 && str_starts_with($raw, "\xFE\xFF")) {
        $converted = @mb_convert_encoding(substr($raw, 2), 'UTF-8', 'UTF-16BE');
        if (is_string($converted) && $converted !== '') {
            $raw = $converted;
        }
    } elseif (substr_count($raw, "\0") > (int) (strlen($raw) / 10)) {
        $converted = @mb_convert_encoding($raw, 'UTF-8', 'UTF-16LE');
        if (is_string($converted) && str_contains($converted, 'CREATE')) {
            $raw = $converted;
        }
    }

    return str_replace("\r\n", "\n", str_replace("\r", "\n", $raw));
}

function fate_install_sql_looks_executable(string $stmt): bool
{
    $head = strtoupper(ltrim($stmt));

    return preg_match('/^(CREATE|USE|INSERT|ALTER|DROP|UPDATE|SET)\b/', $head) === 1;
}

/** 讀取 install.sql 並正規化編碼／換行。 */
function fate_install_load_sql_text(string $path): string
{
    if (!is_file($path)) {
        return '';
    }

    $raw = file_get_contents($path);
    if ($raw === false || $raw === '') {
        return '';
    }

    return fate_install_normalize_sql_bytes($raw);
}

/** @return list<string> */
function fate_install_sql_statements(string $path): array
{
    $raw = fate_install_load_sql_text($path);
    if ($raw === '') {
        return [];
    }

    $raw = preg_replace('/\/\*.*?\*\//s', '', $raw) ?? $raw;
    $raw = preg_replace('/^\s*--[^\n]*/m', '', $raw) ?? $raw;
    $raw = preg_replace('/^\s*#[^\n]*/m', '', $raw) ?? $raw;

    $chunks = preg_split('/;\s*(?:\n|$)/', $raw) ?: [];
    $out = [];
    foreach ($chunks as $chunk) {
        $stmt = trim($chunk);
        if ($stmt === '') {
            continue;
        }
        if (!fate_install_sql_looks_executable($stmt)) {
            continue;
        }
        $out[] = $stmt;
    }

    return $out;
}

/** 安裝精靈略過的 SQL（共享主機無 CREATE DATABASE 權限；PDO 已連至指定庫）。 */
function fate_install_sql_skip_for_wizard(string $stmt): bool
{
    $head = strtoupper(ltrim($stmt));

    if (preg_match('/^CREATE\s+DATABASE\b/', $head) === 1) {
        return true;
    }
    if (preg_match('/^USE\s+`/i', $stmt) === 1) {
        return true;
    }

    return false;
}

/**
 * @return list<string>
 */
function fate_install_sql_statements_for_wizard(string $path): array
{
    $statements = fate_install_sql_statements($path);
    $out = [];
    foreach ($statements as $stmt) {
        if (fate_install_sql_skip_for_wizard($stmt)) {
            continue;
        }
        $out[] = $stmt;
    }

    return $out;
}

function fate_install_db_config_path(): string
{
    return dirname(__DIR__) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'db.php';
}

/** 安裝未完成且非環境變數時，可由精靈寫入 db.php（優先於 legacy）。 */
function fate_install_can_configure_db_via_wizard(?PDO $pdo = null): bool
{
    if (fate_db_config_source() === 'env') {
        return false;
    }
    if ($pdo instanceof PDO && fate_install_is_complete($pdo)) {
        return false;
    }
    $err = '';
    $probe = fate_install_probe($err);
    if ($probe['complete']) {
        return false;
    }

    return is_writable(dirname(fate_install_db_config_path()));
}

/**
 * @return array{host:string,name:string,user:string,pass:string,charset:string}
 */
function fate_install_db_form_defaults(): array
{
    $defaults = [
        'host' => 'localhost',
        'name' => '',
        'user' => '',
        'pass' => '',
        'charset' => 'utf8mb4',
    ];

    $path = fate_install_db_config_path();
    if (is_file($path)) {
        try {
            /** @var mixed $cfg */
            $cfg = require $path;
            if (is_array($cfg)) {
                $defaults['host'] = (string) ($cfg['host'] ?? $defaults['host']);
                $defaults['name'] = (string) ($cfg['name'] ?? $defaults['name']);
                $defaults['user'] = (string) ($cfg['user'] ?? $defaults['user']);
                $defaults['pass'] = (string) ($cfg['pass'] ?? $defaults['pass']);
                $defaults['charset'] = (string) ($cfg['charset'] ?? $defaults['charset']);
            }
        } catch (Throwable) {
            /* 略過 */
        }
    }

    if (!empty($_SESSION['fate_install_db_draft']) && is_array($_SESSION['fate_install_db_draft'])) {
        $d = $_SESSION['fate_install_db_draft'];
        foreach (['host', 'name', 'user', 'pass', 'charset'] as $k) {
            if (isset($d[$k]) && is_string($d[$k])) {
                $defaults[$k] = $d[$k];
            }
        }
    }

    return $defaults;
}

/**
 * @param array<string,mixed> $raw
 * @return array{ok:true,cfg:array{host:string,name:string,user:string,pass:string,charset:string}}|array{ok:false,error:string}
 */
function fate_install_parse_db_input(array $raw): array
{
    $host = trim((string) ($raw['host'] ?? ''));
    $name = trim((string) ($raw['name'] ?? ''));
    $user = (string) ($raw['user'] ?? '');
    $pass = (string) ($raw['pass'] ?? '');
    $charset = trim((string) ($raw['charset'] ?? 'utf8mb4'));

    if ($host === '') {
        return ['ok' => false, 'error' => '請填寫資料庫主機。'];
    }
    if ($name === '' || preg_match('/^[A-Za-z0-9_]+$/', $name) !== 1) {
        return ['ok' => false, 'error' => '資料庫名稱須為英數與底線（例如 fate）。'];
    }
    if ($charset === '' || preg_match('/^[A-Za-z0-9_]+$/', $charset) !== 1) {
        return ['ok' => false, 'error' => '字元集名稱格式不正確。'];
    }
    if (strlen($host) > 255 || strlen($user) > 128) {
        return ['ok' => false, 'error' => '連線資訊過長。'];
    }

    return [
        'ok' => true,
        'cfg' => [
            'host' => $host,
            'name' => $name,
            'user' => $user,
            'pass' => $pass,
            'charset' => $charset !== '' ? $charset : 'utf8mb4',
        ],
    ];
}

/**
 * @param array{host:string,name:string,user:string,pass:string,charset:string} $cfg
 * @return array{ok:true,pdo:PDO}|array{ok:false,error:string}
 */
function fate_install_connect_with_config(array $cfg, ?string &$connectError = null): array
{
    try {
        $pdo = fate_db_pdo_from_parts($cfg['host'], $cfg['name'], $cfg['user'], $cfg['pass'], $cfg['charset']);
        $pdo->query('SELECT 1');

        return ['ok' => true, 'pdo' => $pdo];
    } catch (Throwable $e) {
        $msg = $e->getMessage();
        if (!str_contains($msg, 'Unknown database') && !str_contains($msg, '1049')) {
            $connectError = $msg;

            return ['ok' => false, 'error' => 'db_unreachable'];
        }
        $connectError = $msg;

        return ['ok' => false, 'error' => 'db_not_found'];
    }
}

/**
 * @param array{host:string,name:string,user:string,pass:string,charset:string} $cfg
 * @return array{ok:bool,message?:string}
 */
function fate_install_save_db_config(array $cfg): array
{
    if (!fate_install_can_configure_db_via_wizard()) {
        return ['ok' => false, 'message' => '目前無法由安裝精靈寫入資料庫設定。'];
    }

    $path = fate_install_db_config_path();
    $dir = dirname($path);
    if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
        return ['ok' => false, 'message' => '無法建立 config 目錄。'];
    }
    if (!is_writable($dir)) {
        return ['ok' => false, 'message' => 'config 目錄不可寫入，請調整權限。'];
    }

    $esc = static function (string $v): string {
        return str_replace(['\\', "'"], ['\\\\', "\\'"], $v);
    };

    $php = "<?php\n\ndeclare(strict_types=1);\n\n"
        . "/**\n * 資料庫連線設定（由 install/ 安裝精靈寫入）。\n"
        . " * 環境變數 FATE_DB_* 若已設定，仍優先於本檔。\n */\n"
        . "return [\n"
        . "    'host'    => '" . $esc($cfg['host']) . "',\n"
        . "    'name'    => '" . $esc($cfg['name']) . "',\n"
        . "    'user'    => '" . $esc($cfg['user']) . "',\n"
        . "    'pass'    => '" . $esc($cfg['pass']) . "',\n"
        . "    'charset' => '" . $esc($cfg['charset']) . "',\n"
        . "];\n";

    $tmp = $path . '.tmp.' . bin2hex(random_bytes(4));
    if (file_put_contents($tmp, $php, LOCK_EX) === false) {
        return ['ok' => false, 'message' => '無法寫入暫存設定檔。'];
    }
    if (!rename($tmp, $path)) {
        @unlink($tmp);

        return ['ok' => false, 'message' => '無法寫入 config/db.php。'];
    }
    @chmod($path, 0640);
    clearstatcache(true, $path);

    unset($GLOBALS['pdo'], $GLOBALS['fate_app_pdo']);
    $_SESSION['fate_install_db_draft'] = $cfg;

    return ['ok' => true];
}

function fate_install_try_legacy_pdo(?string &$connectError = null): ?PDO
{
    $legacy = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'newbies' . DIRECTORY_SEPARATOR . 'db_connect.php';
    if (!function_exists('fate_path_is_file_safe')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_path_safe.php';
    }
    if (!fate_path_is_file_safe($legacy)) {
        return null;
    }
    $legacyPdo = null;
    try {
        require $legacy;
        if (isset($pdo) && $pdo instanceof PDO) {
            $legacyPdo = $pdo;
        } elseif (isset($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) {
            $legacyPdo = $GLOBALS['pdo'];
        }
    } catch (Throwable $e) {
        $connectError = $e->getMessage();
    }

    return $legacyPdo;
}

/**
 * 依已儲存設定或 legacy 建立 PDO（安裝精靈專用，不載入 config/pdo.php）。
 *
 * @return array{ok:true,pdo:PDO}|array{ok:false,error:string}
 */
function fate_install_connect(?string &$connectError = null): array
{
    if (!fate_admin_member_auth_ready()) {
        return ['ok' => false, 'error' => 'bad_config'];
    }

    $cred = fate_db_credentials();
    if ($cred !== null && $cred['source'] !== 'legacy' && trim($cred['host']) !== '') {
        $conn = fate_install_connect_with_config([
            'host' => $cred['host'],
            'name' => $cred['name'],
            'user' => $cred['user'],
            'pass' => $cred['pass'],
            'charset' => $cred['charset'],
        ], $connectError);
        if ($conn['ok']) {
            $GLOBALS['pdo'] = $conn['pdo'];
            $GLOBALS['fate_app_pdo'] = $conn['pdo'];
        }

        return $conn;
    }

    if (fate_db_config_source() === 'legacy') {
        $legacyPdo = fate_install_try_legacy_pdo($connectError);
        if ($legacyPdo instanceof PDO) {
            $GLOBALS['pdo'] = $legacyPdo;
            $GLOBALS['fate_app_pdo'] = $legacyPdo;

            return ['ok' => true, 'pdo' => $legacyPdo];
        }
    }

    if (fate_db_config_source() === 'missing' || $cred === null) {
        return ['ok' => false, 'error' => 'no_db_config'];
    }

    if ($connectError === null || $connectError === '') {
        $connectError = '無法讀取有效的資料庫連線設定。請於下方表單填寫並儲存。';
    }

    return ['ok' => false, 'error' => 'db_unreachable'];
}

/** @return array{complete:bool,phase:string,pdo:?PDO} */
function fate_install_refresh_phase(PDO $pdo): array
{
    if (!fate_install_schema_ready($pdo)) {
        return ['complete' => false, 'phase' => 'schema_missing', 'pdo' => $pdo];
    }
    if (fate_install_admin_count($pdo, false) > 0 && fate_install_admin_count($pdo, true) === 0) {
        return ['complete' => false, 'phase' => 'admin_inactive', 'pdo' => $pdo];
    }
    if (fate_install_admin_count($pdo, true) === 0) {
        return ['complete' => false, 'phase' => 'no_admin', 'pdo' => $pdo];
    }

    return ['complete' => true, 'phase' => 'complete', 'pdo' => $pdo];
}

function fate_install_user_facing_db_error(string $raw): string
{
    if (str_contains($raw, '找不到資料庫設定')) {
        return '尚未寫入 config/db.php。請在下方步驟 1 填寫連線資訊並按「測試連線並儲存」。';
    }

    $low = strtolower($raw);

    if (str_contains($raw, '1044') || (str_contains($low, 'access denied') && str_contains($low, 'to database'))) {
        return '此帳號無法建立新資料庫，或無權存取您填寫的資料庫名稱。'
            . '請先在主機控制台（HestiaCP、cPanel 等）建立資料庫並建立使用者，'
            . '再填入主機提供的完整「資料庫名稱」與「使用者」（名稱常含帳號前綴，例如 youruser_fate）。';
    }
    if (str_contains($raw, '1045') || (str_contains($low, 'access denied') && !str_contains($low, 'to database'))) {
        return '資料庫帳號或密碼錯誤，請對照主機控制台提供的 MySQL 使用者與密碼。';
    }
    if (
        str_contains($raw, '2002') || str_contains($raw, '2003')
        || str_contains($low, "can't connect") || str_contains($low, 'cannot connect')
        || str_contains($low, 'connection refused')
    ) {
        return '無法連線到資料庫主機。請確認主機位址正確，且 MySQL／MariaDB 服務已啟動。';
    }
    if (str_contains($raw, '1049') || str_contains($low, 'unknown database')) {
        return '找不到指定的資料庫。請先在主機控制台建立資料庫，'
            . '再把控制台顯示的完整資料庫名稱填入（勿自行新建名為 fate 的庫，除非主機已分配該名稱）。';
    }
    if (str_contains($low, 'getaddrinfo') || str_contains($low, 'name or service not known') || str_contains($low, 'no such host')) {
        return '找不到資料庫主機名稱，請確認主機位址是否正確（虛擬主機常為 localhost）。';
    }

    return '連線失敗：' . $raw;
}

function fate_install_user_facing_sql_error(string $raw, string $preview = ''): string
{
    $low = strtolower($raw);

    if (str_contains($raw, '1044') || (str_contains($low, 'access denied') && str_contains($low, 'to database'))) {
        if (preg_match('/^CREATE\s+DATABASE\b/i', ltrim($preview)) === 1) {
            return '主機不允許由程式建立資料庫（常見於 HestiaCP、cPanel 等共享主機）。'
                . '請先在控制台建立資料庫，精靈只會在該庫內建立資料表；若仍失敗請更新至最新版安裝精靈。';
        }

        return '資料庫權限不足：' . fate_install_user_facing_db_error($raw);
    }

    return 'SQL 執行失敗：' . $raw . ($preview !== '' ? '（' . $preview . '）' : '');
}

/**
 * 安裝前的環境體檢，供精靈在第一步顯示。
 *
 * @return list<array{label:string,ok:bool,fatal:bool,hint:string}>
 */
function fate_install_environment_checks(): array
{
    $checks = [];

    $phpOk = PHP_VERSION_ID >= 80000;
    $checks[] = [
        'label' => 'PHP 版本（' . PHP_VERSION . '）',
        'ok' => $phpOk,
        'fatal' => true,
        'hint' => $phpOk ? '' : '需要 PHP 8.0 以上，請升級主機 PHP 版本。',
    ];

    $pdoOk = extension_loaded('pdo_mysql');
    $checks[] = [
        'label' => 'PDO MySQL 擴充',
        'ok' => $pdoOk,
        'fatal' => true,
        'hint' => $pdoOk ? '' : '請在 php.ini 取消註解 extension=pdo_mysql 後重啟伺服器。',
    ];

    $mbOk = extension_loaded('mbstring');
    $checks[] = [
        'label' => 'mbstring 擴充',
        'ok' => $mbOk,
        'fatal' => false,
        'hint' => $mbOk ? '' : '建議在 php.ini 啟用 extension=mbstring，以正確處理中文。',
    ];

    $cfgPath = fate_install_db_config_path();
    $cfgDir = dirname($cfgPath);
    $cfgWritable = is_file($cfgPath) ? is_writable($cfgPath) : is_writable($cfgDir);
    $checks[] = [
        'label' => 'config/ 目錄可寫入',
        'ok' => $cfgWritable,
        'fatal' => fate_db_config_source() !== 'env',
        'hint' => $cfgWritable ? '' : 'config 目錄無寫入權限，精靈無法自動建立 db.php；請調整資料夾權限。',
    ];

    $sqlPath = fate_install_sql_path();
    $sqlOk = is_file($sqlPath);
    $checks[] = [
        'label' => '安裝 SQL 檔（' . basename($sqlPath) . '）',
        'ok' => $sqlOk,
        'fatal' => true,
        'hint' => $sqlOk ? '' : '找不到 sql/install.schema.sql 或 sql/install.sql，請確認檔案已一併上傳。',
    ];

    return $checks;
}

/** Apache 拒絕 HTTP 存取目錄用的 .htaccess 內容。 */
function fate_install_htaccess_deny_all_content(): string
{
    return <<<'HTACCESS'
# 禁止以 HTTP 直接存取（僅供 PHP 本機讀寫）
<IfModule mod_authz_core.c>
    Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
    Order deny,allow
    Deny from all
</IfModule>

HTACCESS;
}

/**
 * 安裝精靈檢查的目錄／檔案權限項目。
 *
 * @return list<array{id:string,label:string,path:string,type:string,need:string,fatal:bool,fixable:bool,fix_mode:?int}>
 */
function fate_install_permission_registry(): array
{
    return [
        [
            'id' => 'config_dir',
            'label' => 'config/ 可寫入',
            'path' => 'config',
            'type' => 'dir',
            'need' => 'writable',
            'fatal' => fate_db_config_source() !== 'env',
            'fixable' => true,
            'fix_mode' => 0755,
        ],
        [
            'id' => 'config_htaccess',
            'label' => 'config/.htaccess（阻擋 HTTP 讀取 db.php）',
            'path' => 'config/.htaccess',
            'type' => 'file',
            'need' => 'exists',
            'fatal' => false,
            'fixable' => true,
            'fix_mode' => 0644,
        ],
        [
            'id' => 'data_dir',
            'label' => 'data/ 可寫入',
            'path' => 'data',
            'type' => 'dir',
            'need' => 'writable',
            'fatal' => true,
            'fixable' => true,
            'fix_mode' => 0755,
        ],
        [
            'id' => 'data_htaccess',
            'label' => 'data/.htaccess（阻擋 HTTP 讀取金鑰 JSON）',
            'path' => 'data/.htaccess',
            'type' => 'file',
            'need' => 'exists',
            'fatal' => false,
            'fixable' => true,
            'fix_mode' => 0644,
        ],
        [
            'id' => 'uploads_dir',
            'label' => 'uploads/ 可寫入',
            'path' => 'uploads',
            'type' => 'dir',
            'need' => 'writable',
            'fatal' => false,
            'fixable' => true,
            'fix_mode' => 0755,
        ],
        [
            'id' => 'uploads_avatars',
            'label' => 'uploads/avatars/ 可寫入',
            'path' => 'uploads/avatars',
            'type' => 'dir',
            'need' => 'writable',
            'fatal' => false,
            'fixable' => true,
            'fix_mode' => 0755,
        ],
        [
            'id' => 'uploads_stores',
            'label' => 'uploads/stores/ 可寫入',
            'path' => 'uploads/stores',
            'type' => 'dir',
            'need' => 'writable',
            'fatal' => false,
            'fixable' => true,
            'fix_mode' => 0755,
        ],
        [
            'id' => 'uploads_blog',
            'label' => 'uploads/blog/ 可寫入（BLOG 正文圖）',
            'path' => 'uploads/blog',
            'type' => 'dir',
            'need' => 'writable',
            'fatal' => false,
            'fixable' => true,
            'fix_mode' => 0755,
        ],
    ];
}

function fate_install_root_path(): string
{
    return dirname(__DIR__);
}

function fate_install_path_join(string $rel): string
{
    return fate_install_root_path() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
}

/** 實際寫入測試（部分主機 is_writable 與實際不符）。 */
function fate_install_probe_dir_writable(string $dir): bool
{
    if (!is_dir($dir)) {
        return false;
    }
    if (!is_writable($dir)) {
        return false;
    }
    $probe = $dir . DIRECTORY_SEPARATOR . '.fate_write_probe_' . getmypid();
    $written = @file_put_contents($probe, '1', LOCK_EX);
    if ($written === false) {
        return false;
    }
    @unlink($probe);

    return true;
}

/**
 * @param array{id:string,label:string,path:string,type:string,need:string,fatal:bool,fixable:bool,fix_mode:?int} $item
 * @return array{id:string,label:string,path:string,type:string,need:string,fatal:bool,fixable:bool,fix_mode:?int,ok:bool,hint:string,status:string}
 */
function fate_install_eval_permission_item(array $item): array
{
    $full = fate_install_path_join($item['path']);
    $ok = false;
    $hint = '';
    $status = 'missing';

    if ($item['need'] === 'writable') {
        if (!is_dir($full)) {
            $hint = '目錄不存在，可嘗試一鍵建立並設定權限。';
            $status = 'missing';
        } elseif (!fate_install_probe_dir_writable($full)) {
            $hint = 'PHP 無法寫入此目錄（常見於權限過嚴或擁有者非網頁程序使用者）。可嘗試一鍵修正；若仍失敗請於主機控制台調整。';
            $status = 'not_writable';
        } else {
            $ok = true;
            $status = 'ok';
        }
    } elseif ($item['need'] === 'exists') {
        if (is_file($full)) {
            $ok = true;
            $status = 'ok';
        } else {
            $hint = '缺少安全防護檔，建議一鍵建立以防設定／金鑰被 HTTP 直接下載。';
            $status = 'missing';
        }
    }

    return array_merge($item, ['ok' => $ok, 'hint' => $hint, 'status' => $status]);
}

/**
 * @return list<array{id:string,label:string,path:string,type:string,need:string,fatal:bool,fixable:bool,fix_mode:?int,ok:bool,hint:string,status:string}>
 */
function fate_install_permission_checks(): array
{
    $out = [];
    foreach (fate_install_permission_registry() as $item) {
        $out[] = fate_install_eval_permission_item($item);
    }

    return $out;
}

function fate_install_permission_has_fatal_failure(array $checks): bool
{
    foreach ($checks as $c) {
        if (!empty($c['fatal']) && empty($c['ok'])) {
            return true;
        }
    }

    return false;
}

function fate_install_permission_can_auto_fix(array $checks): bool
{
    foreach ($checks as $c) {
        if (!empty($c['fixable']) && empty($c['ok'])) {
            return true;
        }
    }

    return false;
}

/**
 * @param array{id:string,label:string,path:string,type:string,need:string,fatal:bool,fixable:bool,fix_mode:?int} $item
 * @return array{ok:bool,message:string}
 */
function fate_install_fix_permission_item(array $item): array
{
    if (empty($item['fixable'])) {
        return ['ok' => false, 'message' => $item['label'] . '：無法自動修正。'];
    }

    $full = fate_install_path_join($item['path']);
    $mode = isset($item['fix_mode']) && is_int($item['fix_mode']) ? $item['fix_mode'] : null;

    if ($item['type'] === 'dir') {
        if (!is_dir($full)) {
            if (!@mkdir($full, $mode ?? 0755, true) && !is_dir($full)) {
                return ['ok' => false, 'message' => $item['label'] . '：無法建立目錄。'];
            }
        }
        if ($mode !== null) {
            @chmod($full, $mode);
        }
        clearstatcache(true, $full);
        if (!fate_install_probe_dir_writable($full)) {
            return ['ok' => false, 'message' => $item['label'] . '：已建立但 PHP 仍無法寫入，請於主機調整擁有者或 ACL。'];
        }

        return ['ok' => true, 'message' => $item['label'] . '：已就緒。'];
    }

    if ($item['type'] === 'file' && $item['need'] === 'exists') {
        $dir = dirname($full);
        if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
            return ['ok' => false, 'message' => $item['label'] . '：上層目錄無法建立。'];
        }
        $content = fate_install_htaccess_deny_all_content();
        if (is_file($full)) {
            return ['ok' => true, 'message' => $item['label'] . '：已存在。'];
        }
        if (@file_put_contents($full, $content, LOCK_EX) === false) {
            return ['ok' => false, 'message' => $item['label'] . '：無法寫入檔案。'];
        }
        if ($mode !== null) {
            @chmod($full, $mode);
        }

        return ['ok' => true, 'message' => $item['label'] . '：已建立。'];
    }

    return ['ok' => false, 'message' => $item['label'] . '：不支援的自動修正。'];
}

/**
 * @return array{ok:bool,fixed:list<string>,failed:list<string>,messages:list<string>}
 */
function fate_install_fix_permissions(): array
{
    $fixed = [];
    $failed = [];
    $messages = [];

    foreach (fate_install_permission_registry() as $item) {
        $current = fate_install_eval_permission_item($item);
        if ($current['ok']) {
            continue;
        }
        $res = fate_install_fix_permission_item($item);
        $messages[] = $res['message'];
        if ($res['ok']) {
            $fixed[] = $item['id'];
        } else {
            $failed[] = $item['id'];
        }
    }

    return [
        'ok' => $failed === [],
        'fixed' => $fixed,
        'failed' => $failed,
        'messages' => $messages,
    ];
}

function fate_install_pdo_database_name(PDO $pdo): ?string
{
    if (!function_exists('fate_db_pdo_current_database')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_db_connect.php';
    }

    return fate_db_pdo_current_database($pdo);
}

function fate_install_schema_ready(PDO $pdo): bool
{
    return fate_admin_members_table_exists($pdo);
}

function fate_install_admin_count(PDO $pdo, bool $activeOnly = false): int
{
    if (!fate_install_schema_ready($pdo)) {
        return 0;
    }

    $qt = fate_admin_members_qualified_table();
    if ($qt === null) {
        return 0;
    }

    $ac = FATE_MEMBER_ACTIVE_COLUMN;
    $sql = 'SELECT COUNT(*) FROM ' . $qt . " WHERE LOWER(TRIM(`role`)) = 'admin'";
    if ($activeOnly) {
        $sql .= ' AND `' . str_replace('`', '``', $ac) . '` = 1';
    }

    try {
        return (int) $pdo->query($sql)->fetchColumn();
    } catch (Throwable) {
        return 0;
    }
}

function fate_install_is_complete(PDO $pdo): bool
{
    return fate_install_schema_ready($pdo) && fate_install_admin_count($pdo, true) > 0;
}

/**
 * @return array{
 *   complete:bool,
 *   phase:string,
 *   message:?string,
 *   pdo:?PDO,
 *   db_summary:?array<string,mixed>
 * }
 */
function fate_install_probe(?string &$connectError = null): array
{
    $summary = fate_db_config_summary();
    $base = [
        'complete' => false,
        'phase' => 'unknown',
        'message' => null,
        'pdo' => null,
        'db_summary' => $summary,
    ];

    if (!fate_admin_member_auth_ready()) {
        $base['phase'] = 'bad_config';

        return $base;
    }

    if (fate_db_config_source() === 'missing') {
        $base['phase'] = 'no_db_config';

        return $base;
    }

    $conn = fate_install_connect($connectError);
    if (!$conn['ok']) {
        $base['phase'] = (string) ($conn['error'] ?? 'db_unreachable');
        if ($connectError !== null && $connectError !== '') {
            $base['message'] = $connectError;
        }

        return $base;
    }

    /** @var PDO $pdo */
    $pdo = $conn['pdo'];
    $base['pdo'] = $pdo;

    if (!fate_install_schema_ready($pdo)) {
        $base['phase'] = 'schema_missing';

        return $base;
    }

    if (fate_install_admin_count($pdo, false) > 0 && fate_install_admin_count($pdo, true) === 0) {
        $base['phase'] = 'admin_inactive';
        $base['complete'] = false;

        return $base;
    }

    if (fate_install_admin_count($pdo, true) === 0) {
        $base['phase'] = 'no_admin';

        return $base;
    }

    $base['phase'] = 'complete';
    $base['complete'] = true;

    return $base;
}

/** 未安裝完成時導向 install/（install 頁本身略過）。 */
function fate_install_maybe_redirect(): void
{
    if (fate_install_is_install_request()) {
        return;
    }

    $err = '';
    $probe = fate_install_probe($err);
    if ($probe['complete']) {
        return;
    }

    if ($probe['phase'] === 'admin_inactive') {
        return;
    }

    if (!in_array($probe['phase'], ['schema_missing', 'no_admin', 'no_db_config', 'db_unreachable', 'bad_config'], true)) {
        return;
    }

    $from = basename(str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? '')));
    $params = ['from' => $from];
    if (isset($_SERVER['QUERY_STRING']) && is_string($_SERVER['QUERY_STRING']) && $_SERVER['QUERY_STRING'] !== '') {
        parse_str($_SERVER['QUERY_STRING'], $extra);
        if (is_array($extra)) {
            foreach ($extra as $k => $v) {
                if (is_string($k) && $k !== '' && $k !== 'from') {
                    $params[$k] = is_scalar($v) ? (string) $v : '';
                }
            }
        }
    }
    $target = fate_install_to_absolute(fate_install_web_path('install/') . '?' . http_build_query($params));
    header('Location: ' . $target, true, 302);
    exit;
}

/**
 * @return array{ok:bool,message?:string,executed?:int}
 */
function fate_install_run_schema(PDO $pdo): array
{
    $path = fate_install_sql_path();
    if (!is_file($path)) {
        return ['ok' => false, 'message' => '找不到 sql/install.sql'];
    }

    $statements = fate_install_sql_statements_for_wizard($path);
    if ($statements === []) {
        return ['ok' => false, 'message' => 'install.sql 無有效 SQL 陳述（精靈已略過 CREATE DATABASE／USE，僅建立資料表）'];
    }

    $executed = 0;
    try {
        foreach ($statements as $i => $stmt) {
            $pdo->exec($stmt);
            ++$executed;
        }
    } catch (Throwable $e) {
        $idx = $executed + 1;
        $preview = preg_replace('/\s+/', ' ', $stmt ?? '') ?? '';
        if (strlen($preview) > 96) {
            $preview = substr($preview, 0, 96) . '…';
        }

        return [
            'ok' => false,
            'message' => '第 ' . $idx . ' 段 SQL 失敗：' . fate_install_user_facing_sql_error($e->getMessage(), $preview),
            'executed' => $executed,
        ];
    }

    return ['ok' => true, 'executed' => $executed];
}

/**
 * @return array{ok:true,member:array{id:int,username:string,display_name:string}}|array{ok:false,error:string}
 */
function fate_install_create_first_admin(
    PDO $pdo,
    string $usernameRaw,
    string $plainPassword,
    string $displayNameRaw,
    string $emailRaw = ''
): array {
    if (!fate_install_schema_ready($pdo)) {
        return ['ok' => false, 'error' => '資料表尚未建立，請先執行安裝 SQL。'];
    }

    if (fate_install_admin_count($pdo, false) > 0) {
        return ['ok' => false, 'error' => '已有管理員帳號，請改用後台登入。'];
    }

    $uv = fate_public_member_validate_username_candidate($usernameRaw);
    if (!$uv['ok']) {
        return ['ok' => false, 'error' => $uv['err'] ?? '帳號格式不正確'];
    }
    $username = $uv['username'];

    $plainPwd = trim($plainPassword);
    if ($plainPwd === '') {
        return ['ok' => false, 'error' => '請輸入密碼。'];
    }
    $pwLen = function_exists('mb_strlen') ? mb_strlen($plainPwd, 'UTF-8') : strlen($plainPwd);
    if ($pwLen < 8) {
        return ['ok' => false, 'error' => '密碼至少 8 字元。'];
    }
    $plainPwd = substr($plainPwd, 0, 72);

    $dn = trim($displayNameRaw);
    if ($dn === '') {
        $dn = $username;
    } else {
        $dv = fate_public_member_validate_display_name_candidate($dn);
        if (!$dv['ok']) {
            return ['ok' => false, 'error' => $dv['err'] ?? '顯示名稱格式不正確。'];
        }
        $dn = $dv['display_name'];
    }

    $GLOBALS['fate_member_table_columns_reload'] = true;
    $cols = fate_member_table_columns($pdo);
    if (!isset($cols['username'], $cols['password'])) {
        return ['ok' => false, 'error' => '會員表結構不完整。'];
    }

    $db = FATE_MEMBER_DATABASE;
    $tbl = FATE_MEMBER_TABLE;
    try {
        $st = $pdo->prepare(sprintf(
            'SELECT `id` FROM `%s`.`%s` WHERE `username` = ? LIMIT 1',
            str_replace('`', '``', $db),
            str_replace('`', '``', $tbl)
        ));
        $st->execute([$username]);
        if ((int) $st->fetchColumn() > 0) {
            return ['ok' => false, 'error' => '此帳號已被使用。'];
        }
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => '無法查詢會員表：' . $e->getMessage()];
    }

    $mailNorm = strtolower(trim($emailRaw));
    if ($mailNorm !== '') {
        $validEv = filter_var($mailNorm, FILTER_VALIDATE_EMAIL);
        if ($validEv === false) {
            return ['ok' => false, 'error' => 'Email 格式不正確。'];
        }
        $mailNorm = strtolower(trim((string) $validEv));
    }

    /** @var array<string, mixed> $rowVals */
    $rowVals = [
        'username' => $username,
        'password' => fate_member_password_hash_new($plainPwd),
        'display_name' => $dn,
    ];

    $acCol = FATE_MEMBER_ACTIVE_COLUMN;
    $acLow = strtolower($acCol);
    if ($acLow !== '' && isset($cols[$acLow])) {
        $rowVals[$acCol] = 1;
    } elseif (isset($cols['status'])) {
        $rowVals['status'] = 1;
    } else {
        return ['ok' => false, 'error' => '會員表缺少啟用欄位。'];
    }

    if (isset($cols['role'])) {
        $rowVals['role'] = 'admin';
    }

    if ($mailNorm !== '' && isset($cols['email'])) {
        $rowVals['email'] = $mailNorm;
        if (isset($cols['email_verified_at'])) {
            $rowVals['email_verified_at'] = date('Y-m-d H:i:s');
        }
    }

    if (isset($cols['approved_at'])) {
        $rowVals['approved_at'] = date('Y-m-d H:i:s');
    }

    if (isset($cols['member_tier'])) {
        $rowVals['member_tier'] = 'formal';
    }

    if (isset($cols['ncoin'])) {
        $rowVals['ncoin'] = 0;
    }

    try {
        $colNames = array_keys($rowVals);
        $quoted = [];
        foreach ($colNames as $cn) {
            $quoted[] = '`' . str_replace('`', '``', (string) $cn) . '`';
        }
        $holders = implode(', ', array_fill(0, count($rowVals), '?'));
        $sql = sprintf(
            'INSERT INTO `%s`.`%s` (%s) VALUES (%s)',
            str_replace('`', '``', $db),
            str_replace('`', '``', $tbl),
            implode(', ', $quoted),
            $holders
        );
        $ins = $pdo->prepare($sql);
        $ins->execute(array_values($rowVals));
        $newId = (int) $pdo->lastInsertId();
        if ($newId <= 0) {
            return ['ok' => false, 'error' => '建立帳號失敗。'];
        }

        return [
            'ok' => true,
            'member' => [
                'id' => $newId,
                'username' => $username,
                'display_name' => $dn,
            ],
        ];
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => $e->getMessage()];
    }
}

function fate_install_csrf_token(): string
{
    if (empty($_SESSION['fate_install_csrf'])) {
        $_SESSION['fate_install_csrf'] = bin2hex(random_bytes(24));
    }

    return (string) $_SESSION['fate_install_csrf'];
}

function fate_install_csrf_check(?string $token): bool
{
    return is_string($token) && isset($_SESSION['fate_install_csrf'])
        && hash_equals((string) $_SESSION['fate_install_csrf'], $token);
}

function fate_install_asset_url(string $rel): string
{
    return fate_install_web_path(ltrim(str_replace('\\', '/', $rel), '/'));
}

function fate_install_step_states(string $phase, ?PDO $pdo): array
{
    $dbDone = $pdo instanceof PDO;
    $schemaDone = $pdo instanceof PDO && fate_install_schema_ready($pdo);
    $adminDone = $pdo instanceof PDO && fate_install_admin_count($pdo, true) > 0;

    $active = match ($phase) {
        'schema_missing' => 2,
        'no_admin' => 3,
        'complete' => 4,
        default => 1,
    };

    return [
        ['label' => '1 資料庫', 'state' => $dbDone ? 'done' : ($active === 1 ? 'active' : 'pending')],
        ['label' => '2 資料表', 'state' => $schemaDone ? 'done' : ($active === 2 ? 'active' : 'pending')],
        ['label' => '3 管理員', 'state' => $adminDone ? 'done' : ($active === 3 ? 'active' : 'pending')],
    ];
}

function fate_install_phase_label(string $phase): string
{
    return match ($phase) {
        'no_db_config' => '尚未設定資料庫連線',
        'db_unreachable' => '無法連線資料庫',
        'bad_config' => '會員表設定錯誤',
        'schema_missing' => '資料表尚未建立',
        'no_admin' => '尚無管理員帳號',
        'admin_inactive' => '管理員帳號未啟用',
        'complete' => '安裝完成',
        default => '檢查中',
    };
}
