<?php
declare(strict_types=1);

namespace Infolab;

use PDO;
use PDOException;
use PDOStatement;

/**
 * Singleton PDO + helpers de base.
 *
 * Toutes les requêtes passent par des prepared statements (paramètres nommés ou
 * positionnels). Aucune concaténation de variable utilisateur dans le SQL.
 * Les noms de tables/colonnes utilisés en interne sont quotés avec des backticks
 * et viennent toujours du code, jamais d'une entrée utilisateur.
 */
final class Database
{
    private static ?PDO $pdo = null;

    /** Construit (ou retourne) l'instance PDO partagée. */
    public static function pdo(): PDO
    {
        if (self::$pdo !== null) {
            return self::$pdo;
        }

        $host = getenv('DB_HOST') ?: '127.0.0.1';
        $port = getenv('DB_PORT') ?: '3306';
        $name = getenv('DB_NAME') ?: 'infolab';
        $user = getenv('DB_USER') ?: 'infolab';
        $pass = getenv('DB_PASS') ?: '';

        $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $name);
        try {
            self::$pdo = new PDO($dsn, $user, $pass, [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                // EMULATE_PREPARES=false + STRINGIFY_FETCHES=false : les colonnes
                // INT reviennent en int PHP, pas en string. JSON propre par défaut.
                PDO::ATTR_EMULATE_PREPARES   => false,
                PDO::ATTR_STRINGIFY_FETCHES  => false,
                // Force UTC côté connexion : les TIMESTAMP/DATETIME sont stockés tels quels.
                PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00', NAMES utf8mb4",
            ]);
        } catch (PDOException $e) {
            // Visible dans Dozzle via stderr ; détail jamais renvoyé au client.
            error_log('[Database] connection failed: ' . $e->getMessage());
            throw new \ApiException(500, ['error' => 'internal_server_error']);
        }

        return self::$pdo;
    }

    /**
     * Prépare + exécute. Retourne le PDOStatement pour fetch ultérieur si besoin.
     *
     * @param array<string|int,mixed> $params
     */
    public static function run(string $sql, array $params = []): PDOStatement
    {
        $stmt = self::pdo()->prepare($sql);
        $stmt->execute($params);
        return $stmt;
    }

    /**
     * Récupère une seule ligne (ou null).
     *
     * @param array<string|int,mixed> $params
     * @return array<string,mixed>|null
     */
    public static function fetchOne(string $sql, array $params = []): ?array
    {
        $row = self::run($sql, $params)->fetch();
        return $row === false ? null : $row;
    }

    /**
     * Récupère toutes les lignes (liste indexée numériquement, lignes assoc).
     *
     * @param array<string|int,mixed> $params
     * @return array<int,array<string,mixed>>
     */
    public static function fetchAll(string $sql, array $params = []): array
    {
        return self::run($sql, $params)->fetchAll();
    }

    /**
     * Récupère la 1re colonne de la 1re ligne (utile pour COUNT, SELECT id, etc.).
     *
     * @param array<string|int,mixed> $params
     */
    public static function fetchValue(string $sql, array $params = []): mixed
    {
        $row = self::run($sql, $params)->fetch(PDO::FETCH_NUM);
        return $row === false ? null : $row[0];
    }

    /**
     * INSERT générique à partir d'un tableau colonne => valeur.
     * Retourne l'auto-increment.
     *
     * @param array<string,mixed> $data
     */
    public static function insert(string $table, array $data): int
    {
        if ($data === []) {
            throw new \InvalidArgumentException('Database::insert: data vide');
        }
        $cols = array_keys($data);
        $colsSql = implode(',', array_map(static fn($c) => '`' . $c . '`', $cols));
        $phSql   = implode(',', array_map(static fn($c) => ':' . $c, $cols));
        $sql     = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $colsSql, $phSql);

        $params = [];
        foreach ($data as $k => $v) {
            $params[':' . $k] = $v;
        }
        self::run($sql, $params);
        return (int) self::pdo()->lastInsertId();
    }

    /**
     * UPDATE générique. Le WHERE est fourni en SQL + ses propres params
     * (préfixés avec : pour éviter les collisions avec les colonnes SET).
     *
     * @param array<string,mixed>      $data
     * @param array<string,mixed>      $whereParams ex: [':id' => 42]
     */
    public static function update(string $table, array $data, string $whereSql, array $whereParams = []): int
    {
        if ($data === []) {
            return 0;
        }
        $set = implode(',', array_map(static fn($c) => '`' . $c . '`=:' . $c, array_keys($data)));
        $sql = sprintf('UPDATE `%s` SET %s WHERE %s', $table, $set, $whereSql);

        $params = [];
        foreach ($data as $k => $v) {
            $params[':' . $k] = $v;
        }
        foreach ($whereParams as $k => $v) {
            $params[$k] = $v;
        }
        return self::run($sql, $params)->rowCount();
    }

    /**
     * DELETE générique.
     *
     * @param array<string,mixed> $whereParams
     */
    public static function delete(string $table, string $whereSql, array $whereParams = []): int
    {
        $sql = sprintf('DELETE FROM `%s` WHERE %s', $table, $whereSql);
        return self::run($sql, $whereParams)->rowCount();
    }
}
