<?php

declare(strict_types=1);

namespace PpPhp\Core\Databases;

use PpPhp\Core\PlatformSettings;

/**
 * Per-database size quota.
 *
 * WHY A QUOTA AT ALL: PPPHP is a development platform. A project here holds a
 * schema and a little demo data; the real, large database lives on whatever
 * hosting the user deploys the generated app to. Capping each database keeps this
 * server's disk, and the primary→backup clone, predictable.
 *
 * WHAT THIS ENFORCES, HONESTLY:
 * MySQL has no per-schema disk quota, and shared hosting gives us no filesystem
 * quota either — so nothing here can physically stop a row being inserted by a
 * user's own generated app. What this DOES do:
 *   • measure usage accurately and cheaply,
 *   • warn the project owner well before the limit,
 *   • give platform-side operations (imports, demo-data generation, table
 *     creation) a single `mayGrow()` gate to refuse at the boundary we DO control.
 * Claiming more than that would be dishonest, so the UI says "usage", not
 * "enforced limit".
 *
 * Sizes come from `information_schema`, which is an ESTIMATE. That is fine here —
 * a few percent either way does not matter for a usage bar. It is emphatically
 * NOT fine for change detection, which is why that uses checksums instead
 * (see TableDigest).
 */
final class DatabaseQuota
{
    public const SETTING = 'platform.db_quota';

    /** Default cap per database. */
    public const DEFAULT_LIMIT = 5 * 1024 * 1024;          // 5 MB

    /** Fraction at which the user is told to pay attention. */
    private const WARN_AT = 0.75;
    private const CRITICAL_AT = 0.90;

    /** The configured cap in bytes. */
    public static function limit(): int
    {
        $v = (int) (PlatformSettings::get(self::SETTING)['value']['bytes'] ?? 0);
        return $v > 0 ? $v : self::DEFAULT_LIMIT;
    }

    public static function setLimit(int $bytes): void
    {
        // Floor of 1 MB: anything smaller cannot hold a usable schema and would
        // put every project over quota the moment it was created.
        $bytes = max(1024 * 1024, min(1024 * 1024 * 1024, $bytes));
        PlatformSettings::set(self::SETTING, ['value' => ['bytes' => $bytes]]);
    }

    /**
     * Usage for one database, as the user should see it.
     *
     * @return array{bytes:int,limit:int,percent:float,level:string,label:string,message:string}
     */
    public static function status(int $bytes): array
    {
        $limit   = self::limit();
        $percent = $limit > 0 ? min(999.0, ($bytes / $limit) * 100) : 0.0;

        if ($bytes >= $limit) {
            $level = 'over';
            $msg = 'This database is over the ' . self::human($limit) . ' limit. '
                 . 'Remove some demo data, or ask the administrator to raise it.';
        } elseif ($percent >= self::CRITICAL_AT * 100) {
            $level = 'critical';
            $msg = 'Almost full — only ' . self::human(max(0, $limit - $bytes)) . ' left.';
        } elseif ($percent >= self::WARN_AT * 100) {
            $level = 'warn';
            $msg = self::human(max(0, $limit - $bytes)) . ' of headroom left.';
        } else {
            $level = 'ok';
            $msg = self::human(max(0, $limit - $bytes)) . ' free.';
        }

        return [
            'bytes'   => $bytes,
            'limit'   => $limit,
            'percent' => round($percent, 1),
            'level'   => $level,
            'label'   => self::human($bytes) . ' / ' . self::human($limit),
            'message' => $msg,
        ];
    }

    /**
     * May a platform-side operation add roughly this much?
     *
     * The gate for things WE control — CSV/Excel import, demo-data generation,
     * restoring a snapshot. It deliberately does not pretend to police writes made
     * by a user's own generated application.
     *
     * @return array{allowed:bool,message:string,status:array<string,mixed>}
     */
    public static function mayGrow(int $currentBytes, int $addingBytes = 0): array
    {
        $st = self::status($currentBytes + max(0, $addingBytes));
        if ($currentBytes + max(0, $addingBytes) < self::limit()) {
            return ['allowed' => true, 'message' => '', 'status' => $st];
        }
        return ['allowed' => false, 'status' => $st, 'message' =>
            'This would take the database past its ' . self::human(self::limit()) . ' limit ('
            . self::human($currentBytes) . ' used). Remove some data first, or ask the '
            . 'administrator to raise the limit.'];
    }

    /**
     * Guard the database on the CURRENT connection.
     *
     * Self-contained on purpose: it measures via `SELECT DATABASE()` on whatever
     * connection is active, so it works from the console and from a generated app
     * alike without needing to resolve which managed database this is.
     *
     * FAILS OPEN, deliberately. This same runtime is copied into every generated
     * app, and those are usually deployed to the customer's OWN hosting where our
     * allowance is meaningless. If the limit cannot be read (no platform settings
     * — i.e. not running on PPPHP) the import proceeds. Blocking someone's import
     * on their own server because our quota table was missing would be a far worse
     * failure than letting a database on our platform run slightly over.
     *
     * @param int $addingBytes rough size of what is about to be written
     * @return array{allowed:bool,message:string,checked:bool}
     */
    public static function guardCurrentConnection(int $addingBytes = 0): array
    {
        $free = ['allowed' => true, 'message' => '', 'checked' => false];

        // No platform settings => not running on PPPHP => not our database to police.
        if (!class_exists(PlatformSettings::class)) return $free;

        try {
            $limit = self::limit();
            if ($limit <= 0) return $free;

            $pdo = \PpPhp\Core\DB::Pdo();
            if (!$pdo instanceof \PDO) return $free;

            $current = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();

            // The PLATFORM's own database is not a project database and is not
            // under quota — it legitimately holds every project, licence and
            // visitor row and is far larger than any per-project allowance.
            // Measuring it would refuse imports that have nothing to do with a
            // user's storage.
            if ($current === '' || $current === self::platformDbName()) return $free;

            $used = (int) $pdo->query(
                'SELECT COALESCE(SUM(data_length + index_length), 0)
                   FROM information_schema.TABLES WHERE table_schema = DATABASE()'
            )->fetchColumn();
        } catch (\Throwable $e) {
            return $free;                      // cannot tell => do not block
        }

        $g = self::mayGrow($used, $addingBytes);
        return ['allowed' => $g['allowed'], 'message' => $g['message'], 'checked' => true];
    }

    /** The console's own schema name, from config — never under quota. */
    private static function platformDbName(): string
    {
        try {
            $f = dirname(__DIR__, 2) . '/config/database.php';
            $cfg = is_file($f) ? (array) require $f : [];
            return (string) ($cfg['dbname'] ?? $cfg['database'] ?? '');
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Rough on-disk cost of rows about to be inserted.
     *
     * Intentionally crude — this decides whether to warn near a boundary, not
     * anything requiring precision. The multiplier covers index and row overhead,
     * which routinely doubles the raw value size in InnoDB.
     *
     * @param array<int,array<string,mixed>> $rows
     */
    public static function estimateRows(array $rows): int
    {
        $bytes = 0;
        foreach ($rows as $r) {
            foreach ((array) $r as $v) {
                $bytes += is_scalar($v) ? strlen((string) $v) : 8;
            }
        }
        return (int) ($bytes * 2.2);
    }

    public static function human(int $b): string
    {
        if ($b <= 0) return '0 B';
        $u = ['B', 'KB', 'MB', 'GB'];
        $i = max(0, min((int) floor(log($b, 1024)), 3));
        return round($b / (1024 ** $i), $i >= 2 ? 1 : 0) . ' ' . $u[$i];
    }
}
