<?php
namespace App\Repositories;

use App\Database\Connection;
use App\Security\RbacCatalog;
use PDO;

class RbacRepository
{
    private Connection $db;
    private ?bool $seededGrantsCache = null;

    public function __construct(Connection $db)
    {
        $this->db = $db;
    }

    private function tablesAvailable(): bool
    {
        return $this->db->hasColumn('rbac_roles', 'id') && $this->db->hasColumn('rbac_capabilities', 'id');
    }

    private function scopeTablesAvailable(): bool
    {
        return $this->tablesAvailable()
            && $this->db->hasColumn('rbac_role_data_scopes', 'id')
            && $this->db->hasColumn('rbac_role_scope_users', 'role_data_scope_id');
    }

    private function userOverrideTablesAvailable(): bool
    {
        return $this->tablesAvailable()
            && $this->db->hasColumn('rbac_user_capabilities', 'id')
            && $this->db->hasColumn('rbac_user_data_scopes', 'id')
            && $this->db->hasColumn('rbac_user_scope_users', 'user_data_scope_id');
    }

    public function isRbacInstalled(): bool
    {
        return $this->tablesAvailable();
    }

    /**
     * True only when the RBAC tables exist AND have been seeded with role grants.
     * A schema imported from a SQL dump (or a half-run install) can have the tables
     * but zero rows in rbac_role_capabilities — in which case can() would deny every
     * non-admin role. Callers use this to fall back to RbacCatalog::defaultGrants(),
     * which is byte-identical to what the seeder would have written.
     */
    public function hasSeededRoleGrants(): bool
    {
        if ($this->seededGrantsCache !== null) {
            return $this->seededGrantsCache;
        }
        if (!$this->tablesAvailable()) {
            return $this->seededGrantsCache = false;
        }
        try {
            $val = $this->db->getPdo()->query("SELECT 1 FROM rbac_role_capabilities LIMIT 1")->fetchColumn();
            return $this->seededGrantsCache = ($val !== false);
        } catch (\Throwable $e) {
            return $this->seededGrantsCache = false;
        }
    }

    public function getRoles(bool $includeInactive = true): array
    {
        if (!$this->tablesAvailable()) return [];
        $pdo = $this->db->getPdo();
        $sql = "SELECT * FROM rbac_roles";
        if (!$includeInactive) $sql .= " WHERE is_active = 1";
        $sql .= " ORDER BY is_system DESC, title ASC";
        return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    public function syncCatalog(bool $enforceAdminReadOnly = false): void
    {
        if (!$this->tablesAvailable()) return;

        $pdo = $this->db->getPdo();
        $roles = [
            ['role_key' => 'admin', 'title' => 'مدیر عامل', 'is_system' => 1],
            ['role_key' => 'sales_manager', 'title' => 'مدیر فروش', 'is_system' => 1],
            ['role_key' => 'management', 'title' => 'مدیریت', 'is_system' => 1],
            ['role_key' => 'crm_specialist', 'title' => 'کارشناس CRM', 'is_system' => 1],
            ['role_key' => 'agent', 'title' => 'کارشناس فروش', 'is_system' => 1],
            ['role_key' => 'accountant', 'title' => 'مالی', 'is_system' => 1],
            ['role_key' => 'support', 'title' => 'پشتیبانی', 'is_system' => 1],
            ['role_key' => 'system', 'title' => 'سیستم', 'is_system' => 1],
        ];

        $roleStmt = $pdo->prepare("
            INSERT INTO rbac_roles (role_key, title, is_system, is_active, created_at, updated_at)
            VALUES (?, ?, ?, 1, NOW(), NOW())
            ON DUPLICATE KEY UPDATE title = VALUES(title), is_system = VALUES(is_system), updated_at = NOW()
        ");
        foreach ($roles as $role) {
            $roleStmt->execute([$role['role_key'], $role['title'], $role['is_system']]);
        }

        $capStmt = $pdo->prepare("
            INSERT INTO rbac_capabilities (capability_key, title, category, module, parent_key, sort_order, is_system, is_active, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, 1, 1, NOW(), NOW())
            ON DUPLICATE KEY UPDATE
                title = VALUES(title),
                category = VALUES(category),
                module = VALUES(module),
                parent_key = VALUES(parent_key),
                sort_order = VALUES(sort_order),
                is_active = 1,
                updated_at = NOW()
        ");
        foreach (RbacCatalog::capabilities() as $cap) {
            $capStmt->execute([$cap['key'], $cap['title'], $cap['cat'], $cap['module'], $cap['parent'], $cap['sort']]);
        }

        if ($enforceAdminReadOnly) {
            $grantMap = RbacCatalog::defaultGrants();
            $this->replaceSystemRoleCapabilities('admin', $grantMap['admin'] ?? []);
            $this->replaceSystemRoleCapabilities('system', $grantMap['system'] ?? []);
        }
    }

    private function replaceSystemRoleCapabilities(string $roleKey, array $capabilityKeys): void
    {
        $role = $this->getRoleByKey($roleKey);
        if (!$role) return;

        $roleId = (int)($role['id'] ?? 0);
        if ($roleId <= 0) return;

        $pdo = $this->db->getPdo();
        $pdo->prepare("DELETE FROM rbac_role_capabilities WHERE role_id = ?")->execute([$roleId]);
        $this->insertRoleCapabilities($roleId, $capabilityKeys);
    }

    public function getRoleByKey(string $roleKey): ?array
    {
        if (!$this->tablesAvailable()) return null;
        $stmt = $this->db->getPdo()->prepare("SELECT * FROM rbac_roles WHERE role_key = ? LIMIT 1");
        $stmt->execute([$roleKey]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    public function getRoleById(int $id): ?array
    {
        if (!$this->tablesAvailable() || $id <= 0) return null;
        $stmt = $this->db->getPdo()->prepare("SELECT * FROM rbac_roles WHERE id = ? LIMIT 1");
        $stmt->execute([$id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    public function getCapabilities(bool $includeInactive = false): array
    {
        if (!$this->tablesAvailable()) return [];
        $pdo = $this->db->getPdo();
        $sql = "SELECT * FROM rbac_capabilities";
        if (!$includeInactive) $sql .= " WHERE is_active = 1";
        $sql .= " ORDER BY category ASC, sort_order ASC, title ASC";
        return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    public function getRoleCapabilitiesByRoleKey(string $roleKey, bool $activeRoleOnly = true): array
    {
        if (!$this->tablesAvailable()) return [];
        $activeSql = $activeRoleOnly ? " AND r.is_active = 1" : "";
        $stmt = $this->db->getPdo()->prepare("
            SELECT c.capability_key, rc.effect
            FROM rbac_roles r
            INNER JOIN rbac_role_capabilities rc ON rc.role_id = r.id
            INNER JOIN rbac_capabilities c ON c.id = rc.capability_id
            WHERE r.role_key = ? AND c.is_active = 1{$activeSql}
        ");
        $stmt->execute([$roleKey]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    public function getRoleDataScopesByRoleKey(string $roleKey, bool $activeRoleOnly = true): array
    {
        if (!$this->scopeTablesAvailable()) return [];
        $activeSql = $activeRoleOnly ? " AND r.is_active = 1" : "";
        $stmt = $this->db->getPdo()->prepare("
            SELECT s.id, s.resource_key, s.scope_type, s.meta_json
            FROM rbac_roles r
            INNER JOIN rbac_role_data_scopes s ON s.role_id = r.id
            WHERE r.role_key = ?{$activeSql}
            ORDER BY s.resource_key ASC
        ");
        $stmt->execute([$roleKey]);
        return $this->attachRoleScopeUsers($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
    }

    public function getUserCapabilityEffects(int $userId): array
    {
        if (!$this->userOverrideTablesAvailable() || $userId <= 0) return [];
        $stmt = $this->db->getPdo()->prepare("
            SELECT c.capability_key, uc.effect
            FROM rbac_user_capabilities uc
            INNER JOIN rbac_capabilities c ON c.id = uc.capability_id
            WHERE uc.user_id = ? AND c.is_active = 1
        ");
        $stmt->execute([$userId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    public function getUserDataScopes(int $userId): array
    {
        if (!$this->userOverrideTablesAvailable() || $userId <= 0) return [];
        $stmt = $this->db->getPdo()->prepare("
            SELECT id, resource_key, scope_type, meta_json
            FROM rbac_user_data_scopes
            WHERE user_id = ?
            ORDER BY resource_key ASC
        ");
        $stmt->execute([$userId]);
        return $this->attachUserScopeUsers($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
    }

    public function getActiveScopeUsersByIds(array $ids): array
    {
        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn($v) => $v > 0)));
        if (empty($ids)) return [];

        $pdo = $this->db->getPdo();
        $ph = implode(',', array_fill(0, count($ids), '?'));
        $activeSql = $this->db->hasColumn('users', 'is_active') ? " AND COALESCE(is_active, 0) = 1" : "";
        $didarSql = $this->db->hasColumn('users', 'didar_user_id') ? " AND COALESCE(didar_user_id, '') <> ''" : "";
        $stmt = $pdo->prepare("SELECT id, didar_user_id FROM users WHERE id IN ($ph){$activeSql}{$didarSql}");
        $stmt->execute($ids);
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
        $map = [];
        foreach ($rows as $row) {
            $map[(int)$row['id']] = (string)($row['didar_user_id'] ?? '');
        }
        return $map;
    }

    public function getDidarIdsForUserIds(array $ids): array
    {
        $users = $this->getActiveScopeUsersByIds($ids);
        return array_values(array_unique(array_filter(array_map('strval', array_values($users)))));
    }

    public function saveRole(array $data): array
    {
        if (!$this->tablesAvailable()) {
            throw new \RuntimeException('RBAC tables are not available');
        }

        $pdo = $this->db->getPdo();
        $id = (int)($data['id'] ?? 0);
        $roleKey = trim((string)($data['role_key'] ?? ''));
        $title = trim((string)($data['title'] ?? ''));
        $isActive = isset($data['is_active']) ? (int)$data['is_active'] : 1;
        $templateFrom = trim((string)($data['template_from_role_key'] ?? ''));
        $metaJson = $data['meta_json'] ?? null;

        if ($roleKey === '' || $title === '') {
            throw new \InvalidArgumentException('role_key و title الزامی است');
        }

        if ($id > 0) {
            $current = $this->getRoleById($id);
            if (!$current) throw new \RuntimeException('نقش یافت نشد');
            if ((string)$current['role_key'] === 'admin' && !$isActive) {
                throw new \RuntimeException('نقش admin قابل غیرفعال شدن نیست');
            }
            if ((int)($current['is_system'] ?? 0) === 1 && (string)$current['role_key'] !== $roleKey) {
                throw new \RuntimeException('کلید نقش سیستمی قابل تغییر نیست');
            }
            $stmt = $pdo->prepare("
                UPDATE rbac_roles
                SET role_key = ?, title = ?, is_active = ?, template_from_role_key = ?, meta_json = ?, updated_at = NOW()
                WHERE id = ?
            ");
            $stmt->execute([$roleKey, $title, $isActive ? 1 : 0, ($templateFrom !== '' ? $templateFrom : null), $metaJson, $id]);
            return $this->getRoleById($id) ?: [];
        }

        $stmt = $pdo->prepare("
            INSERT INTO rbac_roles (role_key, title, is_system, is_active, template_from_role_key, meta_json, created_at, updated_at)
            VALUES (?, ?, 0, ?, ?, ?, NOW(), NOW())
        ");
        $stmt->execute([$roleKey, $title, $isActive ? 1 : 0, ($templateFrom !== '' ? $templateFrom : null), $metaJson]);
        return $this->getRoleById((int)$pdo->lastInsertId()) ?: [];
    }

    /**
     * Delete a custom role. System roles and 'admin' are protected, and a role that
     * is still assigned to users cannot be deleted (the caller must reassign them
     * first) so no user is left pointing at a non-existent role. FK cascade removes
     * the role's capabilities, data scopes, and scope-user rows.
     */
    public function deleteRole(string $roleKey): void
    {
        if (!$this->tablesAvailable()) {
            throw new \RuntimeException('RBAC tables are not available');
        }
        $role = $this->getRoleByKey($roleKey);
        if (!$role) {
            throw new \RuntimeException('نقش یافت نشد');
        }
        if ((int)($role['is_system'] ?? 0) === 1) {
            throw new \RuntimeException('نقش سیستمی قابل حذف نیست');
        }
        if ((string)($role['role_key'] ?? '') === 'admin') {
            throw new \RuntimeException('نقش admin قابل حذف نیست');
        }

        $pdo = $this->db->getPdo();
        $cnt = $pdo->prepare("SELECT COUNT(*) FROM users WHERE role = ?");
        $cnt->execute([$roleKey]);
        $assigned = (int)$cnt->fetchColumn();
        if ($assigned > 0) {
            throw new \RuntimeException("این نقش به {$assigned} کاربر اختصاص داده شده است؛ ابتدا نقش آن کاربران را تغییر دهید");
        }

        $del = $pdo->prepare("DELETE FROM rbac_roles WHERE id = ?");
        $del->execute([(int)$role['id']]);
    }

    public function cloneRoleFromTemplate(string $newRoleKey, string $newTitle, string $templateRoleKey): array
    {
        if (!$this->tablesAvailable()) {
            throw new \RuntimeException('RBAC tables are not available');
        }

        $pdo = $this->db->getPdo();
        $newRoleKey = trim($newRoleKey);
        $newTitle = trim($newTitle);
        $templateRoleKey = trim($templateRoleKey);
        if ($newRoleKey === '' || $newTitle === '' || $templateRoleKey === '') {
            throw new \InvalidArgumentException('new_role_key/new_title/template_role_key الزامی است');
        }

        $template = $this->getRoleByKey($templateRoleKey);
        if (!$template) throw new \RuntimeException('نقش تمپلیت یافت نشد');

        $pdo->beginTransaction();
        try {
            $created = $this->saveRole([
                'role_key' => $newRoleKey,
                'title' => $newTitle,
                'is_active' => 1,
                'template_from_role_key' => $templateRoleKey,
            ]);
            $newRoleId = (int)($created['id'] ?? 0);
            $templateRoleId = (int)($template['id'] ?? 0);
            if ($newRoleId <= 0 || $templateRoleId <= 0) {
                throw new \RuntimeException('خطا در ایجاد نقش جدید از تمپلیت');
            }

            $pdo->prepare("
                INSERT INTO rbac_role_capabilities (role_id, capability_id, effect, created_at)
                SELECT ?, capability_id, effect, NOW()
                FROM rbac_role_capabilities
                WHERE role_id = ?
                ON DUPLICATE KEY UPDATE effect = VALUES(effect)
            ")->execute([$newRoleId, $templateRoleId]);

            foreach ($this->getRoleDataScopesByRoleKey($templateRoleKey, false) as $scope) {
                $this->upsertRoleScope($newRoleId, $scope);
            }

            $pdo->commit();
            return $this->getRoleById($newRoleId) ?: [];
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            throw $e;
        }
    }

    public function saveRoleCapabilitiesByRoleKey(string $roleKey, array $capabilityKeys): void
    {
        if (!$this->tablesAvailable()) throw new \RuntimeException('RBAC tables are not available');
        $role = $this->getRoleByKey($roleKey);
        if (!$role) throw new \RuntimeException('نقش یافت نشد');
        $roleId = (int)$role['id'];
        if ($roleId <= 0) throw new \RuntimeException('شناسه نقش نامعتبر است');

        $capabilityKeys = $this->normalizeKeys($capabilityKeys);
        $pdo = $this->db->getPdo();

        $pdo->beginTransaction();
        try {
            $pdo->prepare("DELETE FROM rbac_role_capabilities WHERE role_id = ?")->execute([$roleId]);
            $this->insertRoleCapabilities($roleId, $capabilityKeys);
            $pdo->commit();
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            throw $e;
        }
    }

    public function saveRoleDataScopesByRoleKey(string $roleKey, array $scopes): void
    {
        if (!$this->scopeTablesAvailable()) throw new \RuntimeException('RBAC scope tables are not available');
        $role = $this->getRoleByKey($roleKey);
        if (!$role) throw new \RuntimeException('نقش یافت نشد');
        $roleId = (int)$role['id'];
        if ($roleId <= 0) throw new \RuntimeException('شناسه نقش نامعتبر است');

        // Replace semantics: the payload is the complete desired scope set. Resources
        // omitted from it are removed (cascades to rbac_role_scope_users), so an admin
        // can downgrade or clear scopes — not just add them. An empty payload clears all
        // role scopes, after which the role falls back to its default scope type.
        $normalized = $this->normalizeScopePayload($scopes);

        $pdo = $this->db->getPdo();
        $pdo->beginTransaction();
        try {
            $pdo->prepare("DELETE FROM rbac_role_data_scopes WHERE role_id = ?")->execute([$roleId]);
            foreach ($normalized as $scope) {
                $this->upsertRoleScope($roleId, $scope);
            }
            $pdo->commit();
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            throw $e;
        }
    }

    public function saveUserCapabilityOverrides(int $userId, array $allowKeys, array $denyKeys): void
    {
        if (!$this->userOverrideTablesAvailable()) throw new \RuntimeException('RBAC user override tables are not available');
        $this->assertUserExists($userId);

        $allowKeys = $this->normalizeKeys($allowKeys);
        $denyKeys = $this->normalizeKeys($denyKeys);
        $denySet = array_flip($denyKeys);
        $allowKeys = array_values(array_filter($allowKeys, static fn($key) => !isset($denySet[$key])));

        $pdo = $this->db->getPdo();
        $pdo->beginTransaction();
        try {
            $pdo->prepare("DELETE FROM rbac_user_capabilities WHERE user_id = ?")->execute([$userId]);
            $this->insertUserCapabilities($userId, $allowKeys, 'allow');
            $this->insertUserCapabilities($userId, $denyKeys, 'deny');
            $pdo->commit();
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            throw $e;
        }
    }

    public function saveUserDataScopes(int $userId, array $scopes): void
    {
        if (!$this->userOverrideTablesAvailable()) throw new \RuntimeException('RBAC user override tables are not available');
        $this->assertUserExists($userId);
        // Replace semantics (see saveRoleDataScopesByRoleKey): the payload is the full
        // desired set; omitted resources are removed, an empty payload clears all
        // per-user scope overrides so the user falls back to their role scopes.
        $normalized = $this->normalizeScopePayload($scopes);

        $pdo = $this->db->getPdo();
        $pdo->beginTransaction();
        try {
            $pdo->prepare("DELETE FROM rbac_user_data_scopes WHERE user_id = ?")->execute([$userId]);
            foreach ($normalized as $scope) {
                $this->upsertUserScope($userId, $scope);
            }
            $pdo->commit();
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            throw $e;
        }
    }

    private function attachRoleScopeUsers(array $scopes): array
    {
        if (empty($scopes)) return [];
        $ids = array_values(array_filter(array_map(static fn($r) => (int)($r['id'] ?? 0), $scopes)));
        $map = [];
        if (!empty($ids)) {
            $ph = implode(',', array_fill(0, count($ids), '?'));
            $stmt = $this->db->getPdo()->prepare("SELECT role_data_scope_id, user_id FROM rbac_role_scope_users WHERE role_data_scope_id IN ($ph)");
            $stmt->execute($ids);
            foreach (($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) as $row) {
                $map[(int)$row['role_data_scope_id']][] = (int)$row['user_id'];
            }
        }
        foreach ($scopes as &$scope) {
            $scope['selected_user_ids'] = $map[(int)$scope['id']] ?? [];
        }
        unset($scope);
        return $scopes;
    }

    private function attachUserScopeUsers(array $scopes): array
    {
        if (empty($scopes)) return [];
        $ids = array_values(array_filter(array_map(static fn($r) => (int)($r['id'] ?? 0), $scopes)));
        $map = [];
        if (!empty($ids)) {
            $ph = implode(',', array_fill(0, count($ids), '?'));
            $stmt = $this->db->getPdo()->prepare("SELECT user_data_scope_id, selected_user_id FROM rbac_user_scope_users WHERE user_data_scope_id IN ($ph)");
            $stmt->execute($ids);
            foreach (($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) as $row) {
                $map[(int)$row['user_data_scope_id']][] = (int)$row['selected_user_id'];
            }
        }
        foreach ($scopes as &$scope) {
            $scope['selected_user_ids'] = $map[(int)$scope['id']] ?? [];
        }
        unset($scope);
        return $scopes;
    }

    private function normalizeScopePayload(array $scopes): array
    {
        $normalized = [];
        foreach ($scopes as $scope) {
            if (!is_array($scope)) {
                throw new \InvalidArgumentException('ساختار scope نامعتبر است');
            }
            $resourceKey = trim((string)($scope['resource_key'] ?? ''));
            $scopeType = trim((string)($scope['scope_type'] ?? ''));
            if (!RbacCatalog::isValidResource($resourceKey)) {
                throw new \InvalidArgumentException('resource_key نامعتبر است: ' . $resourceKey);
            }
            if (!RbacCatalog::isValidScopeType($scopeType)) {
                throw new \InvalidArgumentException('scope_type نامعتبر است: ' . $scopeType);
            }
            $selectedUserIds = array_values(array_unique(array_filter(array_map('intval', (array)($scope['selected_user_ids'] ?? [])), static fn($v) => $v > 0)));
            if ($scopeType === 'selected_users') {
                $validUsers = $this->getActiveScopeUsersByIds($selectedUserIds);
                $missing = array_values(array_diff($selectedUserIds, array_keys($validUsers)));
                if (!empty($missing)) {
                    throw new \InvalidArgumentException('کاربرهای scope نامعتبر یا غیرفعال هستند: ' . implode(',', $missing));
                }
                if (empty($validUsers)) {
                    throw new \InvalidArgumentException('selected_users بدون کاربر معتبر قابل ذخیره نیست');
                }
                $selectedUserIds = array_keys($validUsers);
            } else {
                $selectedUserIds = [];
            }
            $metaJson = null;
            if (array_key_exists('meta_json', $scope)) {
                $metaJson = is_string($scope['meta_json'])
                    ? $scope['meta_json']
                    : json_encode($scope['meta_json'], JSON_UNESCAPED_UNICODE);
            }
            $normalized[$resourceKey] = [
                'resource_key' => $resourceKey,
                'scope_type' => $scopeType,
                'meta_json' => $metaJson,
                'selected_user_ids' => $selectedUserIds,
            ];
        }
        return array_values($normalized);
    }

    private function upsertRoleScope(int $roleId, array $scope): void
    {
        $pdo = $this->db->getPdo();
        $stmt = $pdo->prepare("
            INSERT INTO rbac_role_data_scopes (role_id, resource_key, scope_type, meta_json, created_at, updated_at)
            VALUES (?, ?, ?, ?, NOW(), NOW())
            ON DUPLICATE KEY UPDATE scope_type = VALUES(scope_type), meta_json = VALUES(meta_json), updated_at = NOW()
        ");
        $stmt->execute([$roleId, $scope['resource_key'], $scope['scope_type'], $scope['meta_json'] ?? null]);
        $scopeId = $this->findRoleScopeId($roleId, $scope['resource_key']);
        $this->replaceRoleScopeUsers($scopeId, (array)($scope['selected_user_ids'] ?? []));
    }

    private function upsertUserScope(int $userId, array $scope): void
    {
        $pdo = $this->db->getPdo();
        $stmt = $pdo->prepare("
            INSERT INTO rbac_user_data_scopes (user_id, resource_key, scope_type, meta_json, created_at, updated_at)
            VALUES (?, ?, ?, ?, NOW(), NOW())
            ON DUPLICATE KEY UPDATE scope_type = VALUES(scope_type), meta_json = VALUES(meta_json), updated_at = NOW()
        ");
        $stmt->execute([$userId, $scope['resource_key'], $scope['scope_type'], $scope['meta_json'] ?? null]);
        $scopeId = $this->findUserScopeId($userId, $scope['resource_key']);
        $this->replaceUserScopeUsers($scopeId, (array)($scope['selected_user_ids'] ?? []));
    }

    private function findRoleScopeId(int $roleId, string $resourceKey): int
    {
        $stmt = $this->db->getPdo()->prepare("SELECT id FROM rbac_role_data_scopes WHERE role_id = ? AND resource_key = ? LIMIT 1");
        $stmt->execute([$roleId, $resourceKey]);
        return (int)$stmt->fetchColumn();
    }

    private function findUserScopeId(int $userId, string $resourceKey): int
    {
        $stmt = $this->db->getPdo()->prepare("SELECT id FROM rbac_user_data_scopes WHERE user_id = ? AND resource_key = ? LIMIT 1");
        $stmt->execute([$userId, $resourceKey]);
        return (int)$stmt->fetchColumn();
    }

    private function replaceRoleScopeUsers(int $scopeId, array $userIds): void
    {
        if ($scopeId <= 0) throw new \RuntimeException('شناسه scope نامعتبر است');
        $pdo = $this->db->getPdo();
        $pdo->prepare("DELETE FROM rbac_role_scope_users WHERE role_data_scope_id = ?")->execute([$scopeId]);
        if (empty($userIds)) return;
        $stmt = $pdo->prepare("INSERT IGNORE INTO rbac_role_scope_users (role_data_scope_id, user_id, created_at) VALUES (?, ?, NOW())");
        foreach ($userIds as $uid) {
            $stmt->execute([$scopeId, (int)$uid]);
        }
    }

    private function replaceUserScopeUsers(int $scopeId, array $userIds): void
    {
        if ($scopeId <= 0) throw new \RuntimeException('شناسه scope نامعتبر است');
        $pdo = $this->db->getPdo();
        $pdo->prepare("DELETE FROM rbac_user_scope_users WHERE user_data_scope_id = ?")->execute([$scopeId]);
        if (empty($userIds)) return;
        $stmt = $pdo->prepare("INSERT IGNORE INTO rbac_user_scope_users (user_data_scope_id, selected_user_id, created_at) VALUES (?, ?, NOW())");
        foreach ($userIds as $uid) {
            $stmt->execute([$scopeId, (int)$uid]);
        }
    }

    private function insertRoleCapabilities(int $roleId, array $capabilityKeys): void
    {
        $capMap = $this->capabilityIdMap($capabilityKeys);
        if (empty($capMap)) return;
        $stmt = $this->db->getPdo()->prepare("INSERT INTO rbac_role_capabilities (role_id, capability_id, effect, created_at) VALUES (?, ?, 'allow', NOW())");
        foreach ($capabilityKeys as $key) {
            if (isset($capMap[$key])) $stmt->execute([$roleId, $capMap[$key]]);
        }
    }

    private function insertUserCapabilities(int $userId, array $capabilityKeys, string $effect): void
    {
        $capMap = $this->capabilityIdMap($capabilityKeys);
        if (empty($capMap)) return;
        $stmt = $this->db->getPdo()->prepare("INSERT INTO rbac_user_capabilities (user_id, capability_id, effect, created_at) VALUES (?, ?, ?, NOW())");
        foreach ($capabilityKeys as $key) {
            if (isset($capMap[$key])) $stmt->execute([$userId, $capMap[$key], $effect]);
        }
    }

    private function capabilityIdMap(array $capabilityKeys): array
    {
        $capabilityKeys = $this->normalizeKeys($capabilityKeys);
        if (empty($capabilityKeys)) return [];
        $ph = implode(',', array_fill(0, count($capabilityKeys), '?'));
        $stmt = $this->db->getPdo()->prepare("SELECT id, capability_key FROM rbac_capabilities WHERE capability_key IN ($ph)");
        $stmt->execute($capabilityKeys);
        $map = [];
        foreach (($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) as $row) {
            $map[(string)$row['capability_key']] = (int)$row['id'];
        }
        return $map;
    }

    private function normalizeKeys(array $keys): array
    {
        return array_values(array_unique(array_filter(array_map(static fn($v) => trim((string)$v), $keys), static fn($v) => $v !== '')));
    }

    private function assertUserExists(int $userId): void
    {
        if ($userId <= 0) throw new \InvalidArgumentException('شناسه کاربر نامعتبر است');
        $stmt = $this->db->getPdo()->prepare("SELECT id FROM users WHERE id = ? LIMIT 1");
        $stmt->execute([$userId]);
        if (!$stmt->fetchColumn()) throw new \RuntimeException('کاربر یافت نشد');
    }
}
