<?php
declare(strict_types=1);

/**
 * Zero-input child data isolation for ordinary shared hosting.
 *
 * The installer receives only the mother application's existing MySQL
 * connection. Every generated bot gets a deterministic, unguessable table
 * namespace inside that database plus a protected per-bot storage directory.
 * No hosting-panel API, CREATE DATABASE, CREATE USER or GRANT privilege is
 * requested or required.
 */
final class ChildDatabaseManager
{
    private array $mainDb;
    private string $storageRoot;

    public function __construct(private array $config)
    {
        $this->mainDb = (array)($config['db'] ?? []);
        foreach (['host', 'name', 'user'] as $required) {
            if (trim((string)($this->mainDb[$required] ?? '')) === '') {
                throw new RuntimeException("تنظیم دیتابیس مادر ناقص است: {$required}");
            }
        }

        // Legacy child_db drivers (auto/cPanel/mysql_admin) are intentionally
        // ignored. This package always uses the portable zero-input mode.
        $settings = (array)($config['child_db'] ?? []);
        $configuredStorage = trim((string)($settings['storage_root'] ?? ''));
        $this->storageRoot = $configuredStorage !== ''
            ? $this->normalizeStorageRoot($configuredStorage)
            : dirname(__DIR__) . '/storage/child_databases';
    }

    public function databaseNameForSecret(string $secret): string
    {
        $this->assertSafeSecret($secret);
        $name = trim((string)$this->mainDb['name']);
        if (!preg_match('/^[A-Za-z0-9_$\-]{1,64}$/', $name)) {
            throw new RuntimeException('نام دیتابیس مادر برای فضای اختصاصی ربات معتبر نیست.');
        }
        return $name;
    }

    public function databaseUserForSecret(string $secret): string
    {
        $this->assertSafeSecret($secret);
        $user = trim((string)$this->mainDb['user']);
        if ($user === '') throw new RuntimeException('یوزر دیتابیس مادر خالی است.');
        return $user;
    }

    public function tablePrefixForSecret(string $secret): string
    {
        $this->assertSafeSecret($secret);
        // 18 characters. The random webhook secret makes this namespace
        // infeasible to guess and leaves plenty of room under MySQL's limit.
        return 'mb' . substr(hash('sha256', 'table:' . $secret), 0, 14) . '_';
    }

    /**
     * @return array{host:string,port:int,name:string,user:string,pass:string,charset:string,grant_host:string,provider:string,storage_path:string,table_prefix:string}
     */
    public function provisionForSecret(string $secret): array
    {
        $this->assertSafeSecret($secret);
        $child = [
            'host' => (string)($this->mainDb['host'] ?? 'localhost'),
            'port' => (int)($this->mainDb['port'] ?? 3306),
            'name' => $this->databaseNameForSecret($secret),
            'user' => $this->databaseUserForSecret($secret),
            'pass' => (string)($this->mainDb['pass'] ?? ''),
            'charset' => (string)($this->mainDb['charset'] ?? 'utf8mb4'),
            'grant_host' => '',
            'provider' => 'table_namespace',
            'table_prefix' => $this->tablePrefixForSecret($secret),
        ];

        try {
            $pdo = Db::make($child);
            $pdo->query('SELECT 1')->fetchColumn();
            $collisionCheck = $pdo->prepare(
                "SELECT COUNT(*) FROM information_schema.tables
                 WHERE table_schema = DATABASE() AND LEFT(table_name, CHAR_LENGTH(?)) = ?"
            );
            $collisionCheck->execute([(string)$child['table_prefix'], (string)$child['table_prefix']]);
            if ((int)$collisionCheck->fetchColumn() > 0) {
                throw new RuntimeException('فضای جدول تولیدشده از قبل وجود دارد؛ برای جلوگیری از تداخل، ساخت ربات متوقف شد.');
            }
            $storagePath = $this->createStorageRegistry($secret, $child);
            return $child + ['storage_path' => $storagePath];
        } catch (Throwable $e) {
            // createStorageRegistry can fail after creating part of the folder.
            // Never leave a partial per-bot registry behind.
            try { $this->removeStorageRegistry($secret); } catch (Throwable) {}
            throw $e;
        }
    }

    public function drop(
        string $database,
        ?string $username = null,
        ?string $grantHost = null,
        ?string $secret = null,
        ?string $provider = null
    ): void {
        if ($secret === null || $secret === '') {
            throw new RuntimeException('برای حذف اطلاعات اختصاصی ربات، شناسه امن ربات لازم است.');
        }
        $this->assertSafeSecret($secret);

        $mainName = $this->databaseNameForSecret($secret);
        $provider = trim((string)$provider);
        if ($database !== '' && !hash_equals($mainName, $database) && $provider !== 'table_namespace') {
            throw new RuntimeException(
                'این رکورد متعلق به روش قدیمی دیتابیس فیزیکی است و برای جلوگیری از حذف اشتباه، خودکار حذف نشد.'
            );
        }

        $this->dropTableNamespace($secret);
        $this->removeStorageRegistry($secret);
    }

    /** @return array{host:string,port:int,name:string,user:string,pass:string,charset:string} */
    public function connectionConfig(string $database, string $username, string $password): array
    {
        return [
            'host' => (string)($this->mainDb['host'] ?? 'localhost'),
            'port' => (int)($this->mainDb['port'] ?? 3306),
            'name' => $database,
            'user' => $username,
            'pass' => $password,
            'charset' => (string)($this->mainDb['charset'] ?? 'utf8mb4'),
        ];
    }

    public function prefix(): string
    {
        return 'mb';
    }

    public function grantHost(): string
    {
        return '';
    }

    public function storageRoot(): string
    {
        return $this->storageRoot;
    }

    /**
     * Runs a real create/write/read/drop test without touching mother tables.
     */
    public function verifyProvisioningPrivilege(): void
    {
        $secret = 'probe-' . bin2hex(random_bytes(20));
        $prefix = $this->tablePrefixForSecret($secret);
        $table = $prefix . 'isolation_probe';
        $quoted = self::quoteIdentifier($table);
        $pdo = Db::make($this->mainDb);

        try {
            $pdo->exec("CREATE TABLE {$quoted} (id INT PRIMARY KEY, marker VARCHAR(64) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
            $stmt = $pdo->prepare("INSERT INTO {$quoted}(id, marker) VALUES(1, ?)");
            $marker = bin2hex(random_bytes(16));
            $stmt->execute([$marker]);
            $read = (string)$pdo->query("SELECT marker FROM {$quoted} WHERE id=1")->fetchColumn();
            if (!hash_equals($marker, $read)) throw new RuntimeException('تست خواندن و نوشتن فضای اختصاصی ناموفق بود.');

            $child = [
                'name' => $this->databaseNameForSecret($secret),
                'user' => $this->databaseUserForSecret($secret),
                'provider' => 'table_namespace',
                'table_prefix' => $prefix,
            ];
            $this->createStorageRegistry($secret, $child);
        } catch (Throwable $e) {
            throw new RuntimeException(
                'اکانت دیتابیس مادر باید روی همان دیتابیس اجازه CREATE/DROP TABLE و خواندن/نوشتن داشته باشد: ' . $e->getMessage(),
                0,
                $e
            );
        } finally {
            try { $pdo->exec("DROP TABLE IF EXISTS {$quoted}"); } catch (Throwable) {}
            // Safe even when registry creation failed halfway.
            $this->removeStorageRegistry($secret);
        }
    }

    private function dropTableNamespace(string $secret): void
    {
        $prefix = $this->tablePrefixForSecret($secret);
        $pdo = Db::make($this->mainDb);
        $stmt = $pdo->prepare(
            "SELECT table_name FROM information_schema.tables
             WHERE table_schema = DATABASE() AND LEFT(table_name, CHAR_LENGTH(?)) = ?"
        );
        $stmt->execute([$prefix, $prefix]);
        $tables = $stmt->fetchAll(PDO::FETCH_COLUMN);
        if (!$tables) return;

        $pdo->exec('SET FOREIGN_KEY_CHECKS=0');
        try {
            foreach ($tables as $table) {
                $table = (string)$table;
                if (!str_starts_with($table, $prefix) || !preg_match('/^[A-Za-z0-9_]{1,64}$/', $table)) {
                    throw new RuntimeException('نام جدول خارج از فضای ربات شناسایی شد؛ حذف متوقف شد.');
                }
                $pdo->exec('DROP TABLE IF EXISTS ' . self::quoteIdentifier($table));
            }
        } finally {
            $pdo->exec('SET FOREIGN_KEY_CHECKS=1');
        }
    }

    private function escapeLike(string $value): string
    {
        return strtr($value, ['\\' => '\\\\', '_' => '\\_', '%' => '\\%']);
    }

    private function createStorageRegistry(string $secret, array $child): string
    {
        $this->ensureStorageRoot();
        $directory = $this->storageDirectory($secret);
        if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
            throw new RuntimeException('ساخت پوشه اختصاصی ربات ممکن نشد.');
        }
        @chmod($directory, 0700);

        $backupDir = $directory . '/backups';
        if (!is_dir($backupDir) && !mkdir($backupDir, 0700, true) && !is_dir($backupDir)) {
            throw new RuntimeException('ساخت پوشه بکاپ اختصاصی ربات ممکن نشد.');
        }
        @chmod($backupDir, 0700);

        $manifest = [
            'version' => 2,
            'secret_fingerprint' => hash('sha256', $secret),
            'database' => (string)($child['name'] ?? ''),
            'provider' => 'table_namespace',
            'table_prefix' => (string)($child['table_prefix'] ?? ''),
            'created_at' => date(DATE_ATOM),
        ];
        $json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        if (file_put_contents($directory . '/manifest.json', $json . PHP_EOL, LOCK_EX) === false) {
            throw new RuntimeException('ثبت فایل فضای اختصاصی ربات ناموفق بود.');
        }
        @chmod($directory . '/manifest.json', 0600);
        if (!is_file($directory . '/index.html')) @file_put_contents($directory . '/index.html', '', LOCK_EX);
        if (!is_file($directory . '/.htaccess')) {
            @file_put_contents($directory . '/.htaccess', "Require all denied\nDeny from all\n", LOCK_EX);
        }

        return $this->relativeProjectPath($directory);
    }

    private function ensureStorageRoot(): void
    {
        if (!is_dir($this->storageRoot) && !mkdir($this->storageRoot, 0700, true) && !is_dir($this->storageRoot)) {
            throw new RuntimeException('ساخت مسیر ذخیره‌سازی دیتابیس‌های ربات ممکن نشد.');
        }
        @chmod($this->storageRoot, 0700);
        if (!is_file($this->storageRoot . '/.htaccess')) {
            @file_put_contents($this->storageRoot . '/.htaccess', "Require all denied\nDeny from all\n", LOCK_EX);
        }
        if (!is_file($this->storageRoot . '/index.html')) @file_put_contents($this->storageRoot . '/index.html', '', LOCK_EX);
        if (!is_file($this->storageRoot . '/README.txt')) {
            @file_put_contents(
                $this->storageRoot . '/README.txt',
                "Each directory belongs to one generated bot. MySQL tables remain in the mother database under the table_prefix recorded in manifest.json. Passwords are never stored here.\n",
                LOCK_EX
            );
        }
    }

    private function removeStorageRegistry(string $secret): void
    {
        $directory = $this->storageDirectory($secret);
        if (is_dir($directory)) $this->removeDirectory($directory);
    }

    private function storageDirectory(string $secret): string
    {
        $this->assertSafeSecret($secret);
        return rtrim($this->storageRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $secret;
    }

    private function removeDirectory(string $dir): void
    {
        if (is_link($dir)) { @unlink($dir); return; }
        if (!is_dir($dir)) return;
        $items = scandir($dir);
        if ($items === false) return;
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;
            $path = $dir . DIRECTORY_SEPARATOR . $item;
            if (is_dir($path) && !is_link($path)) $this->removeDirectory($path); else @unlink($path);
        }
        @rmdir($dir);
    }

    private function relativeProjectPath(string $absolute): string
    {
        $root = rtrim(dirname(__DIR__), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
        if (str_starts_with($absolute, $root)) {
            return str_replace(DIRECTORY_SEPARATOR, '/', substr($absolute, strlen($root)));
        }
        return $absolute;
    }

    private function normalizeStorageRoot(string $path): string
    {
        $path = trim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path));
        if ($path === '') return dirname(__DIR__) . '/storage/child_databases';
        if (!str_starts_with($path, DIRECTORY_SEPARATOR) && !preg_match('/^[A-Za-z]:\\\\/', $path)) {
            $path = dirname(__DIR__) . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
        }
        $segments = [];
        foreach (explode(DIRECTORY_SEPARATOR, $path) as $segment) {
            if ($segment === '' || $segment === '.') continue;
            if ($segment === '..') throw new RuntimeException('مسیر storage_root نباید شامل .. باشد.');
            $segments[] = $segment;
        }
        $prefix = str_starts_with($path, DIRECTORY_SEPARATOR) ? DIRECTORY_SEPARATOR : '';
        return $prefix . implode(DIRECTORY_SEPARATOR, $segments);
    }

    private function assertSafeSecret(string $secret): void
    {
        if (!preg_match('/^[A-Za-z0-9_-]{16,128}$/', $secret)) {
            throw new RuntimeException('شناسه امن ربات معتبر نیست.');
        }
    }

    public static function quoteIdentifier(string $identifier): string
    {
        if (!preg_match('/^[A-Za-z0-9_$\-]{1,64}$/', $identifier)) {
            throw new RuntimeException('شناسه SQL نامعتبر است.');
        }
        return '`' . str_replace('`', '``', $identifier) . '`';
    }
}
