<?php
declare(strict_types=1);

final class Database {
    private static ?PDO $pdo = null;
    private static bool $schemaChecked = false;
    private static ?string $driver = null;

    public static function driver(): string {
        if (self::$driver !== null) {
            return self::$driver;
        }
        $d = cfg('db.driver', 'sqlite');
        self::$driver = ($d === 'mysql' || $d === 'mariadb') ? 'mysql' : 'sqlite';
        return self::$driver;
    }

    public static function isMysql(): bool {
        return self::driver() === 'mysql';
    }

    public static function pdo(): PDO {
        if (self::$pdo !== null) {
            return self::$pdo;
        }
        if (self::isMysql()) {
            $m = cfg('db.mysql', []);
            $db = $m['database'] ?? 'androidcast_crashes';
            $charset = $m['charset'] ?? 'utf8mb4';
            $socket = trim((string) ($m['socket'] ?? ''));
            // Prefer explicit socket from config (Alpine: TCP 3306 often closed).
            if ($socket !== '') {
                $dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset);
            } else {
                $dsn = sprintf(
                    'mysql:host=%s;port=%d;dbname=%s;charset=%s',
                    $m['host'] ?? '127.0.0.1',
                    (int) ($m['port'] ?? 3306),
                    $db,
                    $charset
                );
            }
            self::$pdo = new PDO($dsn, $m['username'] ?? '', $m['password'] ?? '', [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
            self::ensureSchema();
            return self::$pdo;
        }
        $path = cfg('db.sqlite_path');
        $dir = dirname($path);
        if (!is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
        self::$pdo = new PDO('sqlite:' . $path, null, null, [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        self::$pdo->exec('PRAGMA foreign_keys = ON');
        self::ensureSchema();
        return self::$pdo;
    }

    /** Upsert into report_views (portable). */
    public static function upsertReportView(int $userId, int $reportId, int $viewedAtMs): void {
        $pdo = self::pdo();
        if (self::isMysql()) {
            $sql = 'INSERT INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)
                    ON DUPLICATE KEY UPDATE viewed_at_ms = VALUES(viewed_at_ms)';
        } else {
            $sql = 'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)';
        }
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$userId, $reportId, $viewedAtMs]);
    }

    /** Idempotent schema setup for sqlite and mysql. */
    public static function ensureSchema(): void {
        if (self::$schemaChecked) {
            return;
        }
        self::$schemaChecked = true;
        $pdo = self::$pdo;
        if ($pdo === null) {
            return;
        }
        if (self::isMysql()) {
            self::ensureMysqlSchema($pdo);
            return;
        }
        self::ensureSqliteSchema($pdo);
    }

    private static function ensureSqliteSchema(PDO $pdo): void {
        $need = static function (string $table) use ($pdo): bool {
            return !self::tableExists($pdo, $table);
        };
        if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) {
            self::ensureReportColumns($pdo);
            self::ensureReportListIndexes($pdo);
            self::ensureReportViewsTable($pdo);
            self::ensureRbacSchema($pdo);
            AuthEmailSchema::ensure($pdo);
            return;
        }
        $schemaFile = __DIR__ . '/../sql/schema.sqlite.sql';
        if (!is_readable($schemaFile)) {
            throw new RuntimeException('schema file missing: ' . $schemaFile);
        }
        if (!$need('reports') && !self::reportsSchemaOk($pdo)) {
            $pdo->exec('DROP TABLE IF EXISTS reports');
        }
        $pdo->exec((string) file_get_contents($schemaFile));
        self::ensureReportColumns($pdo);
        self::ensureReportListIndexes($pdo);
        self::ensureReportViewsTable($pdo);
        self::ensureRbacSchema($pdo);
        AuthEmailSchema::ensure($pdo);
    }

    private static function ensureMysqlSchema(PDO $pdo): void {
        if (!self::tableExists($pdo, 'users')) {
            $pdo->exec(
                "CREATE TABLE IF NOT EXISTS users (
                    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                    username VARCHAR(64) NOT NULL,
                    password_hash VARCHAR(255) NOT NULL,
                    role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE KEY uq_users_username (username)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
            );
            $pdo->exec(
                "INSERT IGNORE INTO users (id, username, password_hash, role)
                 VALUES (1, 'admin', '\$2y\$10\$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root')"
            );
        }
        if (!self::tableExists($pdo, 'reports')) {
            $pdo->exec(
                "CREATE TABLE IF NOT EXISTS reports (
                    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                    report_id VARCHAR(128) NOT NULL,
                    fingerprint VARCHAR(64) NOT NULL,
                    crash_type VARCHAR(32) NOT NULL,
                    generated_at_ms BIGINT NOT NULL,
                    received_at_ms BIGINT NOT NULL,
                    device_model VARCHAR(128) NULL,
                    app_version VARCHAR(64) NULL,
                    payload_json LONGTEXT NOT NULL,
                    tags_json LONGTEXT NOT NULL,
                    UNIQUE KEY uq_reports_report_id (report_id),
                    KEY idx_reports_generated (generated_at_ms),
                    KEY idx_reports_received (received_at_ms),
                    KEY idx_reports_fingerprint (fingerprint),
                    KEY idx_reports_fp_received (fingerprint, received_at_ms)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
            );
        } else {
            self::ensureMysqlReportColumns($pdo);
        }
        self::ensureReportListIndexes($pdo);
        self::ensureMysqlReportViewsTable($pdo);
        self::ensureRbacSchema($pdo);
        AuthEmailSchema::ensure($pdo);
        self::ensureTicketsTable($pdo);
    }

    /** Active PDO; opens the pool when callers have not touched the DB yet (e.g. cached Auth session). */
    private static function connection(?PDO $pdo = null): PDO {
        return $pdo ?? self::$pdo ?? self::pdo();
    }

    public static function ticketsTableExists(?PDO $pdo = null): bool {
        return self::tableExists(self::connection($pdo), 'tickets');
    }

    /** @throws RuntimeException when `tickets` is missing (MariaDB needs root migration). */
    public static function requireTicketsTable(?PDO $pdo = null): void {
        $pdo = self::connection($pdo);
        self::ensureTicketsTable($pdo);
        if (!self::tableExists($pdo, 'tickets')) {
            throw new RuntimeException(self::ticketsMigrationHint());
        }
    }

    public static function ticketsMigrationHint(): string {
        return 'Table tickets is missing. On MariaDB run as MySQL root: '
            . 'mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql '
            . '(or ./scripts/migrate-003-tickets.sh from backend/)';
    }

    public static function ensureTicketsTable(?PDO $pdo = null): void {
        $pdo = self::connection($pdo);
        if (self::tableExists($pdo, 'tickets')) {
            return;
        }
        $ok = false;
        if (self::isMysql()) {
            $sql = file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sql') ?: '';
            $sql = preg_replace('/^\s*--.*$/m', '', $sql) ?? $sql;
            $sql = preg_replace('/^\s*USE\s+[^;]+;\s*/mi', '', $sql) ?? $sql;
            $ok = self::execSchemaSql($pdo, trim($sql), 'create tickets mysql');
        } else {
            $ok = self::execSchemaSql(
                $pdo,
                file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '',
                'create tickets sqlite'
            );
        }
        if (!$ok && !self::tableExists($pdo, 'tickets')) {
            error_log('crash_reporter: ' . self::ticketsMigrationHint());
            if (function_exists('log_crash_event')) {
                log_crash_event('schema', self::ticketsMigrationHint());
            }
        }
    }

    /**
     * Run DDL; app DB user often has no ALTER — log and continue (run sql/migrations as root).
     */
    private static function execSchemaSql(PDO $pdo, string $sql, string $label): bool {
        try {
            $pdo->exec($sql);
            return true;
        } catch (PDOException $e) {
            $msg = $label . ': ' . $e->getMessage();
            error_log('crash_reporter schema ' . $msg);
            if (function_exists('log_crash_event')) {
                log_crash_event('schema', $msg);
            }
            return false;
        }
    }

    public static function reportsHaveRbacColumns(?PDO $pdo = null): bool {
        $pdo ??= self::$pdo;
        if ($pdo === null || !self::tableExists($pdo, 'reports')) {
            return false;
        }
        $names = self::columnNames($pdo, 'reports');
        return in_array('company_id', $names, true);
    }

    /** Phase 1 RBAC tables + report company/device columns (idempotent). */
    public static function ensureRbacSchema(?PDO $pdo = null): void {
        $pdo ??= self::$pdo;
        if ($pdo === null) {
            return;
        }
        self::ensureCompaniesTable($pdo);
        self::seedDefaultCompany($pdo);
        self::ensureCompanyMembershipsTable($pdo);
        self::ensureDevicesTable($pdo);
        self::ensureReportCompanyColumns($pdo);
        self::backfillCompanyMemberships($pdo);
        if (!self::reportsHaveRbacColumns($pdo)) {
            error_log(
                'crash_reporter: reports.company_id missing — run as MySQL root: '
                . 'mysql -u root -p androidcast_crashes < sql/migrations/002_reports_company_columns.sql'
            );
        }
    }

    private static function ensureCompaniesTable(PDO $pdo): void {
        if (self::tableExists($pdo, 'companies')) {
            return;
        }
        if (self::isMysql()) {
            self::execSchemaSql(
                $pdo,
                "CREATE TABLE companies (
                    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                    slug VARCHAR(64) NOT NULL,
                    name VARCHAR(128) NOT NULL,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE KEY uq_companies_slug (slug)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
                'create companies'
            );
            return;
        }
        self::execSchemaSql(
            $pdo,
            "CREATE TABLE companies (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                slug TEXT NOT NULL UNIQUE,
                name TEXT NOT NULL,
                created_at TEXT NOT NULL DEFAULT (datetime('now'))
            )",
            'create companies'
        );
    }

    private static function seedDefaultCompany(PDO $pdo): void {
        if (self::isMysql()) {
            self::execSchemaSql(
                $pdo,
                "INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')",
                'seed company'
            );
            return;
        }
        self::execSchemaSql(
            $pdo,
            "INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')",
            'seed company'
        );
    }

    private static function ensureCompanyMembershipsTable(PDO $pdo): void {
        if (self::tableExists($pdo, 'company_memberships')) {
            return;
        }
        if (self::isMysql()) {
            self::execSchemaSql(
                $pdo,
                "CREATE TABLE company_memberships (
                    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                    user_id INT UNSIGNED NOT NULL,
                    company_id INT UNSIGNED NOT NULL,
                    role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
                    permissions_json LONGTEXT NULL,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE KEY uq_membership_user_company (user_id, company_id),
                    KEY idx_membership_company (company_id)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
                'create company_memberships'
            );
            return;
        }
        self::execSchemaSql(
            $pdo,
            "CREATE TABLE company_memberships (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                company_id INTEGER NOT NULL,
                role TEXT NOT NULL DEFAULT 'member',
                permissions_json TEXT,
                created_at TEXT NOT NULL DEFAULT (datetime('now')),
                UNIQUE (user_id, company_id)
            )",
            'create company_memberships'
        );
    }

    private static function ensureDevicesTable(PDO $pdo): void {
        if (self::tableExists($pdo, 'devices')) {
            return;
        }
        if (self::isMysql()) {
            self::execSchemaSql(
                $pdo,
                "CREATE TABLE devices (
                    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                    company_id INT UNSIGNED NOT NULL,
                    external_id VARCHAR(128) NOT NULL,
                    display_name VARCHAR(256) NULL,
                    manufacturer VARCHAR(128) NULL,
                    model VARCHAR(128) NULL,
                    source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
                    registered_at_ms BIGINT NOT NULL,
                    last_seen_at_ms BIGINT NOT NULL,
                    meta_json LONGTEXT NULL,
                    UNIQUE KEY uq_devices_company_external (company_id, external_id),
                    KEY idx_devices_company (company_id)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
                'create devices'
            );
            return;
        }
        self::execSchemaSql(
            $pdo,
            "CREATE TABLE devices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                company_id INTEGER NOT NULL,
                external_id TEXT NOT NULL,
                display_name TEXT,
                manufacturer TEXT,
                model TEXT,
                source TEXT NOT NULL DEFAULT 'auto',
                registered_at_ms INTEGER NOT NULL,
                last_seen_at_ms INTEGER NOT NULL,
                meta_json TEXT,
                UNIQUE (company_id, external_id)
            )",
            'create devices'
        );
    }

    private static function ensureReportCompanyColumns(PDO $pdo): void {
        if (!self::tableExists($pdo, 'reports')) {
            return;
        }
        $names = self::columnNames($pdo, 'reports');
        if (!in_array('company_id', $names, true)) {
            if (self::isMysql()) {
                self::execSchemaSql(
                    $pdo,
                    'ALTER TABLE reports ADD COLUMN company_id INT UNSIGNED NOT NULL DEFAULT 1',
                    'alter reports.company_id'
                );
                self::execSchemaSql(
                    $pdo,
                    'ALTER TABLE reports ADD KEY idx_reports_company (company_id)',
                    'index reports.company_id'
                );
            } else {
                self::execSchemaSql(
                    $pdo,
                    'ALTER TABLE reports ADD COLUMN company_id INTEGER NOT NULL DEFAULT 1',
                    'alter reports.company_id'
                );
            }
            self::execSchemaSql(
                $pdo,
                'UPDATE reports SET company_id = 1 WHERE company_id IS NULL OR company_id = 0',
                'backfill reports.company_id'
            );
        }
        $names = self::columnNames($pdo, 'reports');
        if (!in_array('device_id', $names, true)) {
            if (self::isMysql()) {
                self::execSchemaSql(
                    $pdo,
                    'ALTER TABLE reports ADD COLUMN device_id BIGINT UNSIGNED NULL',
                    'alter reports.device_id'
                );
                self::execSchemaSql(
                    $pdo,
                    'ALTER TABLE reports ADD KEY idx_reports_device (device_id)',
                    'index reports.device_id'
                );
            } else {
                self::execSchemaSql(
                    $pdo,
                    'ALTER TABLE reports ADD COLUMN device_id INTEGER NULL',
                    'alter reports.device_id'
                );
            }
        }
    }

    private static function backfillCompanyMemberships(PDO $pdo): void {
        if (!self::tableExists($pdo, 'users') || !self::tableExists($pdo, 'company_memberships')) {
            return;
        }
        $companyId = 1;
        $rows = $pdo->query('SELECT id, role FROM users')->fetchAll(PDO::FETCH_ASSOC);
        foreach ($rows as $row) {
            $uid = (int) $row['id'];
            $globalRole = strtolower((string) ($row['role'] ?? 'viewer'));
            $companyRole = in_array($globalRole, ['root', 'admin', 'platform_admin'], true) ? 'owner' : 'member';
            if (self::isMysql()) {
                $stmt = $pdo->prepare(
                    'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
                );
            } else {
                $stmt = $pdo->prepare(
                    'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
                );
            }
            $stmt->execute([$uid, $companyId, $companyRole]);
        }
        if (self::isMysql()) {
            $pdo->exec(
                "INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
            );
        } else {
            $pdo->exec(
                "INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
            );
        }
    }

    private static function ensureMysqlReportColumns(PDO $pdo): void {
        $names = self::columnNames($pdo, 'reports');
        if (!in_array('tags_json', $names, true)) {
            if (self::execSchemaSql(
                $pdo,
                'ALTER TABLE reports ADD COLUMN tags_json LONGTEXT NOT NULL',
                'alter reports.tags_json'
            )) {
                self::execSchemaSql(
                    $pdo,
                    "UPDATE reports SET tags_json = '[]' WHERE tags_json IS NULL OR tags_json = ''",
                    'backfill reports.tags_json'
                );
            }
        }
    }

    private static function ensureMysqlReportViewsTable(PDO $pdo): void {
        if (self::tableExists($pdo, 'report_views')) {
            return;
        }
        $pdo->exec(
            'CREATE TABLE IF NOT EXISTS report_views (
                user_id INT UNSIGNED NOT NULL,
                report_id BIGINT UNSIGNED NOT NULL,
                viewed_at_ms BIGINT NOT NULL,
                PRIMARY KEY (user_id, report_id),
                CONSTRAINT fk_report_views_report
                    FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
        );
    }

    private static function ensureReportColumns(PDO $pdo): void {
        $names = self::columnNames($pdo, 'reports');
        if (!in_array('tags_json', $names, true)) {
            self::execSchemaSql(
                $pdo,
                "ALTER TABLE reports ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'",
                'alter reports.tags_json'
            );
        }
    }

    /** Indexes for list API (fingerprint stats join + sort by received_at). */
    private static function ensureReportListIndexes(PDO $pdo): void {
        if (!self::tableExists($pdo, 'reports')) {
            return;
        }
        if (self::isMysql()) {
            $existing = [];
            $stmt = $pdo->query("SHOW INDEX FROM reports");
            if ($stmt !== false) {
                while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
                    $existing[(string) ($row['Key_name'] ?? '')] = true;
                }
            }
            $add = static function (string $name, string $sql) use ($pdo, $existing): void {
                if (!isset($existing[$name])) {
                    self::execSchemaSql($pdo, $sql, 'index reports.' . $name);
                }
            };
            $add('idx_reports_company_fingerprint', 'ALTER TABLE reports ADD KEY idx_reports_company_fingerprint (company_id, fingerprint)');
            $add('idx_reports_company_received', 'ALTER TABLE reports ADD KEY idx_reports_company_received (company_id, received_at_ms)');
            if (!isset($existing['idx_reports_fp_received'])) {
                self::execSchemaSql(
                    $pdo,
                    'ALTER TABLE reports ADD KEY idx_reports_fp_received (fingerprint, received_at_ms)',
                    'index reports.fp_received'
                );
            }
            return;
        }
        self::execSchemaSql(
            $pdo,
            'CREATE INDEX IF NOT EXISTS idx_reports_company_fingerprint ON reports(company_id, fingerprint)',
            'index reports.company_fingerprint'
        );
        self::execSchemaSql(
            $pdo,
            'CREATE INDEX IF NOT EXISTS idx_reports_company_received ON reports(company_id, received_at_ms)',
            'index reports.company_received'
        );
        self::execSchemaSql(
            $pdo,
            'CREATE INDEX IF NOT EXISTS idx_reports_fp_received ON reports(fingerprint, received_at_ms)',
            'index reports.fp_received'
        );
    }

    private static function ensureReportViewsTable(PDO $pdo): void {
        if (self::tableExists($pdo, 'report_views')) {
            return;
        }
        $pdo->exec(
            'CREATE TABLE IF NOT EXISTS report_views (
                user_id INTEGER NOT NULL,
                report_id INTEGER NOT NULL,
                viewed_at_ms INTEGER NOT NULL,
                PRIMARY KEY (user_id, report_id)
            )'
        );
    }

    private static function mysqlQuoteIdentifier(string $name): string {
        return '`' . str_replace('`', '``', $name) . '`';
    }

    public static function tableExists(PDO $pdo, string $table): bool {
        if (self::isMysql()) {
            // App DB users often lack information_schema visibility; SHOW TABLES uses normal grants.
            $stmt = $pdo->query('SHOW TABLES LIKE ' . $pdo->quote($table));
            if ($stmt !== false && $stmt->fetchColumn() !== false) {
                return true;
            }
            try {
                $pdo->query('SELECT 1 FROM ' . self::mysqlQuoteIdentifier($table) . ' LIMIT 0');
                return true;
            } catch (PDOException $e) {
                $sqlState = $e->getCode();
                $errno = (int) ($e->errorInfo[1] ?? 0);
                if ($sqlState === '42S02' || $errno === 1146) {
                    return false;
                }
                throw $e;
            }
        }
        $stmt = $pdo->prepare(
            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1"
        );
        $stmt->execute([$table]);
        return $stmt->fetchColumn() !== false;
    }

    /** Serialize cross-request DDL (MariaDB GET_LOCK). No-op on SQLite. */
    public static function withMigrationLock(PDO $pdo, string $name, callable $fn): void {
        if (!self::isMysql()) {
            $fn();
            return;
        }
        $lockName = 'ac_migrate_' . preg_replace('/[^a-z0-9_]/', '_', strtolower($name));
        $stmt = $pdo->query('SELECT GET_LOCK(' . $pdo->quote($lockName) . ', 90)');
        $got = $stmt !== false && (int) $stmt->fetchColumn() === 1;
        if (!$got) {
            return;
        }
        try {
            $fn();
        } finally {
            $pdo->query('SELECT RELEASE_LOCK(' . $pdo->quote($lockName) . ')');
        }
    }

    /** @return list<string> */
    public static function columnNames(PDO $pdo, string $table): array {
        if (self::isMysql()) {
            $stmt = $pdo->query('SHOW COLUMNS FROM ' . self::mysqlQuoteIdentifier($table));
            if ($stmt === false) {
                return [];
            }
            return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'Field');
        }
        return array_column(
            $pdo->query('PRAGMA table_info(' . preg_replace('/[^a-z0-9_]/i', '', $table) . ')')->fetchAll(PDO::FETCH_ASSOC),
            'name'
        );
    }

    /** @return list<string> */
    public static function listTables(PDO $pdo): array {
        if (self::isMysql()) {
            $stmt = $pdo->query('SHOW TABLES');
            if ($stmt === false) {
                return [];
            }
            return array_values(array_filter($stmt->fetchAll(PDO::FETCH_COLUMN), 'is_string'));
        }
        return $pdo->query(
            "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
        )->fetchAll(PDO::FETCH_COLUMN);
    }

    private static function reportsSchemaOk(PDO $pdo): bool {
        if (!self::tableExists($pdo, 'reports')) {
            return false;
        }
        $names = self::columnNames($pdo, 'reports');
        foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) {
            if (!in_array($col, $names, true)) {
                return false;
            }
        }
        return true;
    }
}
