<?php
declare(strict_types=1);

namespace App\Database;

use PDO;
use PDOException;
use RuntimeException;

final class Connection
{
    /** @var array<string, PDO> */
    private static array $pool = [];
    private static ?string $requestToken = null;

    /**
     * One connection per target, per request.
     *
     * Every call used to open a new MySQL connection, and a single page view
     * makes sixteen of them. That is invisible on a development machine, where
     * max_user_connections is unlimited, and fatal on shared hosting, where it
     * is commonly 10-25: the request exceeds the account's cap, the next
     * connect throws, and the visitor is told the service is unavailable.
     *
     * PHP closes these at the end of the request, so there is nothing to
     * expire; the pool only prevents the same request from opening the same
     * connection repeatedly.
     */
    public static function connect(array $config): PDO
    {
        $requestToken = self::requestToken();
        if (self::$requestToken !== null && self::$requestToken !== $requestToken) {
            // Long-lived PHP workers (including the built-in development server)
            // keep static properties between requests. Drop the previous request's
            // PDO handles so an unfinished transaction or read snapshot cannot
            // make a newly-created topic look missing on the redirect GET.
            self::$pool = [];
        }
        self::$requestToken = $requestToken;
        $database = self::databaseName($config);
        $key = self::poolKey($config, $database);

        return self::$pool[$key] ??= self::createPdo($config, $database);
    }

    /**
     * Drop pooled connections. Only needed when credentials change mid-process,
     * which in practice means the installer and the tests.
     */
    public static function reset(): void
    {
        self::$pool = [];
        self::$requestToken = null;
    }

    private static function requestToken(): string
    {
        $value = $_SERVER['REQUEST_TIME_FLOAT'] ?? $_SERVER['REQUEST_TIME'] ?? '';
        return is_scalar($value) ? (string) $value : '';
    }

    private static function poolKey(array $config, ?string $database): string
    {
        return implode("\0", [
            (string) ($config['driver'] ?? 'mysql'),
            (string) ($config['host'] ?? ''),
            (string) ($config['port'] ?? ''),
            (string) ($config['username'] ?? ''),
            (string) ($config['charset'] ?? ''),
            $database ?? '',
            // Two connections to the same database as the same user but with
            // different passwords cannot both be valid; including it keeps a
            // credential change from silently reusing the old connection.
            hash('sha256', (string) ($config['password'] ?? '')),
        ]);
    }

    public static function connectServer(array $config): PDO
    {
        return self::createPdo($config, null);
    }

    private static function createPdo(array $config, ?string $database): PDO
    {
        $driver = (string) ($config['driver'] ?? 'mysql');

        if ($driver !== 'mysql') {
            throw new RuntimeException('Only PDO MySQL connections are supported.');
        }

        $host = (string) ($config['host'] ?? '127.0.0.1');
        $port = (int) ($config['port'] ?? 3306);
        $charset = self::validateCharset((string) ($config['charset'] ?? 'utf8mb4'));
        $username = (string) ($config['username'] ?? '');
        $password = (string) ($config['password'] ?? '');

        $dsn = sprintf('mysql:host=%s;port=%d;charset=%s', $host, $port, $charset);

        if ($database !== null && $database !== '') {
            $dsn .= ';dbname=' . $database;
        }

        try {
            $pdo = new PDO($dsn, $username, $password, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false,
            ]);
        } catch (PDOException $exception) {
            throw new RuntimeException('Database connection failed.', 0, $exception);
        }

        $pdo->exec("SET NAMES {$charset} COLLATE utf8mb4_unicode_ci");

        // NOW() and CURRENT_TIMESTAMP must agree with the gmdate() writers elsewhere
        // in the app. A numeric offset needs no MySQL timezone tables. Display code
        // converts to the operator's timezone; storage is always UTC.
        $pdo->exec("SET time_zone = '+00:00'");

        return $pdo;
    }

    private static function validateCharset(string $charset): string
    {
        if ($charset !== 'utf8mb4') {
            throw new RuntimeException('Only utf8mb4 charset is supported.');
        }

        return $charset;
    }

    private static function databaseName(array $config): string
    {
        $database = trim((string) ($config['database'] ?? ''));

        if ($database === '') {
            throw new RuntimeException('Database configuration must include a database name.');
        }

        return $database;
    }
}
