<?php

namespace IMATHUZH\Qfq\Core\Helper\Version;

use IMATHUZH\Qfq\Core\Database\Database;

/**
 * Helper class for OIDC Client extension module data management.
 * Handles OIDC provider configurations and FlexForms processing.
 */
class OidcClientModule
{
    /**
     * Get extension module data (currently specific to oidc_client)
     *
     * @param Database $db Database connection
     * @return array Module data for installed extensions with modules
     */
    public static function getExtModuleData(Database $db): array
    {
        $result = [];

        // Check oidc_client
        $oidcData = self::getOidcClientData($db);
        if ($oidcData !== null) {
            $result[EXT_KEY_OIDC_CLIENT] = $oidcData;
        }

        return $result;
    }

    /**
     * Set extension module data (currently specific to oidc_client)
     *
     * @param array|string $config Configuration to set
     * @param Database $db Database connection
     * @return array Results of the operation
     */
    public static function setExtModuleData(array|string $config, Database $db): array
    {
        // Accept JSON string or array
        if (is_string($config)) {
            $config = json_decode($config, true);
            if (json_last_error() !== JSON_ERROR_NONE) {
                throw new \InvalidArgumentException('Invalid JSON: ' . json_last_error_msg());
            }
        }

        $results = [];

        // Handle oidc_client
        if (isset($config[EXT_KEY_OIDC_CLIENT])) {
            $oidcConfig = $config[EXT_KEY_OIDC_CLIENT];
            // Handle double-encoded JSON (string instead of array)
            if (is_string($oidcConfig)) {
                $oidcConfig = json_decode($oidcConfig, true);
                if (json_last_error() !== JSON_ERROR_NONE) {
                    throw new \InvalidArgumentException('Invalid oidc_client JSON: ' . json_last_error_msg());
                }
            }
            $results[EXT_KEY_OIDC_CLIENT] = self::setOidcClientProviders($oidcConfig, $db);
        }

        return $results;
    }

    /**
     * Check if oidc_client extension is installed
     *
     * @return bool
     */
    public static function isOidcClientInstalled(): bool
    {
        $extensions = ExtensionDeploy::getAllExtensionsInfo();
        return isset($extensions[EXT_KEY_OIDC_CLIENT]);
    }

    /**
     * Get oidc_client provider configurations in inputCollectionJson format
     *
     * @param Database $db Database connection
     * @return array|null Provider data or null if not installed
     */
    private static function getOidcClientData(Database $db): ?array
    {
        if (!self::isOidcClientInstalled()) {
            return null;
        }

        // Check if table exists
        $tableCheck = $db->sql(
            "SHOW TABLES LIKE '" . TABLE_T3_OIDC_CLIENT_CONFIG . "'",
            ROW_REGULAR
        );

        if (empty($tableCheck)) {
            return [OIDC_KEY_INSTALLED => true, OIDC_KEY_TABLE_EXISTS => false, OIDC_KEY_PROVIDERS => []];
        }

        // Get all providers
        $providerRows = $db->sql(
            "SELECT * FROM " . TABLE_T3_OIDC_CLIENT_CONFIG . " WHERE deleted = 0 ORDER BY sorting, uid",
            ROW_REGULAR
        );

        // Get available LDAP servers for processor options
        $ldapServers = self::getLdapServers($db);

        // Convert to inputCollectionJson format grouped by categories (based on TCA tabs/palettes)
        $providers = [];
        foreach ($providerRows as $row) {
            $name = $row['name'];
            $providers[$name] = self::formatOidcProviderAsInputCollection($row, $ldapServers);
        }

        return [
            OIDC_KEY_INSTALLED => true,
            OIDC_KEY_TABLE_EXISTS => true,
            OIDC_KEY_PROVIDERS => $providers,
        ];
    }

    /**
     * Format a single oidc provider row as inputCollectionJson
     * Categories based on TCA tabs: general, authentication, client, provider, mapping, notes
     * Includes __label, __desc, __type metadata for each field
     *
     * @param array $row Database row
     * @param array $ldapServers Optional array of available LDAP servers for processor options
     * @return array Formatted as inputCollectionJson with metadata
     */
    private static function formatOidcProviderAsInputCollection(array $row, array $ldapServers = []): array
    {
        // Define field categories based on TCA structure
        // backend/frontend bitmask fields are split into individual booleans
        // Excluded: logo, sorting, disable (from general), lang_parameter (from provider)
        $categories = [
            OIDC_CAT_GENERAL => ['uid', 'name', 'title'],
            OIDC_CAT_AUTHENTICATION => ['mfa', 'backend_enabled', 'backend_create_users', 'backend_unlock_users', 'backend_match_email', 'frontend_enabled', 'frontend_create_users', 'frontend_unlock_users', 'frontend_match_email', 'be_groups', 'fe_groups'],
            OIDC_CAT_CLIENT => ['client_id', 'client_secret', 'client_scopes'],
            OIDC_CAT_PROVIDER => ['callback_forwarding', 'auth_endpoint', 'token_endpoint', 'revoke_endpoint', 'logout_endpoint', 'userinfo_endpoint'],
            OIDC_CAT_MAPPING => ['id_claim', 'usernames', 'emails', 'mapping'],
            OIDC_CAT_NOTES => ['description'],
        ];

        // Expand bitmask fields (backend, frontend) into individual boolean values
        $expandedRow = self::expandOidcBitmaskFields($row);

        // Field metadata: label, description, type
        $fieldMeta = self::getOidcFieldMetadata();

        $result = [];
        foreach ($categories as $category => $fields) {
            $result[$category] = [];
            foreach ($fields as $field) {
                if (array_key_exists($field, $expandedRow)) {
                    $value = $expandedRow[$field];
                    $meta = $fieldMeta[$field] ?? [];
                    $fieldType = $meta[META_KEY_TYPE] ?? 'text';

                    // Convert value based on type (checkbox/number need int, not string)
                    if ($fieldType === 'checkbox' || $fieldType === 'number') {
                        $value = (int)$value;
                    }

                    // Special handling for 'mapping' field: convert JSON to text format
                    // OIDC Client stores mapping as JSON but displays as text in TYPO3
                    if ($field === 'mapping' && is_string($value) && str_starts_with(trim($value), '{')) {
                        $value = self::convertMappingJsonToText($value);
                    }

                    $fieldData = [ICJ_KEY_VALUE => $value];

                    // Add metadata if available
                    if (!empty($meta)) {
                        if (!empty($meta[META_KEY_LABEL])) {
                            $fieldData[ICJ_KEY_LABEL] = $meta[META_KEY_LABEL];
                        }
                        if (!empty($meta[META_KEY_DESC])) {
                            $fieldData[ICJ_KEY_DESC] = $meta[META_KEY_DESC];
                        }
                        if (!empty($fieldType)) {
                            $fieldData[ICJ_KEY_TYPE] = $fieldType;
                        }
                        if (!empty($meta[META_KEY_READONLY])) {
                            $fieldData[ICJ_KEY_READONLY] = true;
                        }
                        if (!empty($meta[META_KEY_CAT])) {
                            $fieldData[ICJ_KEY_CAT] = $meta[META_KEY_CAT];
                        }
                    }

                    $result[$category][$field] = $fieldData;
                }
            }
        }

        // Parse and add processors (FlexForms XML) as separate categories
        if (!empty($row['processors'])) {
            $processorCategories = self::parseOidcProcessorsXml($row['processors'], $ldapServers);
            $result = array_merge($result, $processorCategories);
        }

        // Always ensure processor categories exist with default fields and server options
        // This is important for new providers that don't have processors XML yet
        $processorMeta = self::getOidcProcessorMetadata();
        foreach ([OIDC_CAT_PROCESSORS_LDAP, OIDC_CAT_PROCESSORS_QFQ] as $processorCategory) {
            if (!isset($result[$processorCategory])) {
                $result[$processorCategory] = [];
            }

            // Add default fields from metadata if not present
            $categoryMeta = $processorMeta[$processorCategory] ?? [];
            foreach ($categoryMeta as $fieldName => $meta) {
                if (str_starts_with($fieldName, '__')) {
                    continue; // Skip category metadata
                }
                if (!isset($result[$processorCategory][$fieldName])) {
                    $fieldType = $meta[META_KEY_TYPE] ?? 'text';
                    $fieldData = [ICJ_KEY_VALUE => ($fieldType === 'checkbox') ? 0 : ''];
                    if (!empty($meta[META_KEY_LABEL])) $fieldData[ICJ_KEY_LABEL] = $meta[META_KEY_LABEL];
                    if (!empty($meta[META_KEY_DESC])) $fieldData[ICJ_KEY_DESC] = $meta[META_KEY_DESC];
                    if (!empty($fieldType)) $fieldData[ICJ_KEY_TYPE] = $fieldType;
                    if (!empty($meta[META_KEY_OPTIONS])) $fieldData[ICJ_KEY_OPTIONS] = $meta[META_KEY_OPTIONS];
                    $result[$processorCategory][$fieldName] = $fieldData;
                }
            }

            // Always add server checkboxes for processors_ldap
            if ($processorCategory === OIDC_CAT_PROCESSORS_LDAP && !empty($ldapServers)) {
                foreach ($ldapServers as $server) {
                    $serverFieldName = 'server_' . $server[API_RESULT_KEY_UID];
                    if (!isset($result[$processorCategory][$serverFieldName])) {
                        $result[$processorCategory][$serverFieldName] = [
                            ICJ_KEY_VALUE => 0,
                            ICJ_KEY_LABEL => $server[API_RESULT_KEY_NAME],
                            ICJ_KEY_TYPE => 'checkbox',
                            ICJ_KEY_CAT => 'servers',
                        ];
                    }
                }
            }
        }

        return $result;
    }

    /**
     * Expand bitmask fields (backend, frontend) into individual boolean fields
     * Bitmask: 1=Enabled, 2=Create users, 4=Unlock users
     *
     * @param array $row Database row
     * @return array Row with expanded boolean fields
     */
    public static function expandOidcBitmaskFields(array $row): array
    {
        $expanded = $row;

        // Expand backend bitmask
        // Bits: 1=Enabled, 2=Create users, 4=Unlock users, 8=Match by email
        if (isset($row['backend'])) {
            $backend = (int)$row['backend'];
            $expanded['backend_enabled'] = ($backend & 1) ? 1 : 0;
            $expanded['backend_create_users'] = ($backend & 2) ? 1 : 0;
            $expanded['backend_unlock_users'] = ($backend & 4) ? 1 : 0;
            $expanded['backend_match_email'] = ($backend & 8) ? 1 : 0;
            unset($expanded['backend']);
        }

        // Expand frontend bitmask
        // Bits: 1=Enabled, 2=Create users, 4=Unlock users, 8=Match by email
        if (isset($row['frontend'])) {
            $frontend = (int)$row['frontend'];
            $expanded['frontend_enabled'] = ($frontend & 1) ? 1 : 0;
            $expanded['frontend_create_users'] = ($frontend & 2) ? 1 : 0;
            $expanded['frontend_unlock_users'] = ($frontend & 4) ? 1 : 0;
            $expanded['frontend_match_email'] = ($frontend & 8) ? 1 : 0;
            unset($expanded['frontend']);
        }

        return $expanded;
    }

    /**
     * Convert OIDC mapping JSON to text format
     * OIDC Client stores mapping as JSON: {"username":["<shortname>","<email>"],...}
     * But displays as text: username = <shortname> // <email>
     *
     * @param string $json JSON string
     * @return string Text format or original value if not valid JSON
     */
    public static function convertMappingJsonToText(string $json): string
    {
        $data = json_decode($json, true);
        if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
            return $json; // Return original if not valid JSON
        }

        $lines = [];
        foreach ($data as $field => $templates) {
            if (is_array($templates)) {
                $lines[] = $field . ' = ' . implode(' // ', $templates);
            } elseif (is_string($templates)) {
                $lines[] = $field . ' = ' . $templates;
            }
        }

        return implode("\n", $lines);
    }

    /**
     * Convert OIDC mapping text to JSON format
     * Text format: username = <shortname> // <email>
     * JSON format: {"username":["<shortname>","<email>"]}
     *
     * @param string $text Text format
     * @return string JSON string or original value if not valid text format
     */
    public static function convertMappingTextToJson(string $text): string
    {
        $lines = explode("\n", $text);
        $data = [];

        foreach ($lines as $line) {
            $line = trim($line);
            if (empty($line) || str_starts_with($line, '#')) {
                continue; // Skip empty lines and comments
            }

            if (preg_match('/^(\w+)\s*=\s*(.+)$/', $line, $matches)) {
                $field = $matches[1];
                $templates = array_map('trim', explode('//', $matches[2]));
                $data[$field] = $templates;
            }
        }

        if (empty($data)) {
            return $text; // Return original if no valid mappings found
        }

        return json_encode($data);
    }

    /**
     * Get field metadata for oidc_client fields
     * Based on TCA configuration and locallang_db.xlf labels
     * Types: text, select, checkbox, number, textarea
     *
     * @return array Field metadata with label, desc, type
     */
    private static function getOidcFieldMetadata(): array
    {
        return [
            // General
            'uid' => [
                'label' => 'UID',
                'desc' => 'Unique identifier (auto-generated)',
                'type' => 'number',
                'readonly' => true,
            ],
            'name' => [
                'label' => 'Internal identifier',
                'desc' => 'This identifier is a reference to the OIDC provider used in configurations',
                'type' => 'text',
                'readonly' => true,
            ],
            'title' => [
                'label' => 'The name of the provider',
                'desc' => 'This text is displayed on the button that redirects to the provider\'s page.',
                'type' => 'text',
            ],

            // Authentication - Multi-factor authentication
            'mfa' => [
                'label' => 'MFA implemented by the provider',
                'desc' => 'If Typo3 MFA is configured, enabling this feature prevents Typo3 from asking for the second factor when a user authenticates themself against this provider.',
                'type' => 'checkbox',
            ],

            // Authentication - Backend configuration
            'backend_enabled' => [
                'label' => 'Enable this provider',
                'desc' => 'These settings configure how the provider is used for user authentication: whether the provider is offered, if a local account must exist for a user to be authenticated and whether a disabled user will be unlocked after a successful authentication. The last option allows to match local users not only by username, but also by their emails.',
                'type' => 'checkbox',
                'cat' => 'backend',
            ],
            'backend_create_users' => [
                'label' => 'Create a local user if none exists',
                'type' => 'checkbox',
                'cat' => 'backend',
            ],
            'backend_unlock_users' => [
                'label' => 'Unlock a disabled user',
                'type' => 'checkbox',
                'cat' => 'backend',
            ],
            'backend_match_email' => [
                'label' => 'Match a local user by its email',
                'type' => 'checkbox',
                'cat' => 'backend',
            ],

            // Authentication - Frontend configuration
            'frontend_enabled' => [
                'label' => 'Enable this provider',
                'type' => 'checkbox',
                'cat' => 'frontend',
            ],
            'frontend_create_users' => [
                'label' => 'Create a local user if none exists',
                'type' => 'checkbox',
                'cat' => 'frontend',
            ],
            'frontend_unlock_users' => [
                'label' => 'Unlock a disabled user',
                'type' => 'checkbox',
                'cat' => 'frontend',
            ],
            'frontend_match_email' => [
                'label' => 'Match a local user by its email',
                'type' => 'checkbox',
                'cat' => 'frontend',
            ],

            // Authentication - User groups
            'be_groups' => [
                'label' => 'Default backend groups',
                'desc' => 'Groups assigned to a new user account.',
                'type' => 'text',
            ],
            'fe_groups' => [
                'label' => 'Default frontend groups',
                'desc' => 'Groups assigned to a new user account.',
                'type' => 'text',
            ],

            // Client
            'client_id' => [
                'label' => 'Client identifier',
                'desc' => 'The identifier of the client assigned by the provider. It is usually name "client ID" or "application ID".',
                'type' => 'text',
            ],
            'client_secret' => [
                'label' => 'Client secret',
                'desc' => 'The secret issued by the provider to authenticate requests from the client.',
                'type' => 'text',
            ],
            'client_scopes' => [
                'label' => 'Client scopes',
                'desc' => 'A list of oidc scopes, usually separated with a space (check the provider). The standard scope is \'openid\', but most providers support also \'email\'. Leave empty to use the default values configured on the extension settings page.',
                'type' => 'text',
            ],

            // Provider endpoints
            'callback_forwarding' => [
                'label' => 'User callback forwarding service',
                'desc' => 'When enabled, the extension will use an external server to handle callbacks from this identification provider. The access to this service is configured in the extension settings.',
                'type' => 'checkbox',
            ],
            'auth_endpoint' => [
                'label' => 'Endpoint URI for authentication',
                'desc' => 'The client makes requests to these URIs in order to authenticate a user, manage access token, or fetch user data.',
                'type' => 'text',
            ],
            'token_endpoint' => [
                'label' => 'Endpoint URI for access tokens',
                'type' => 'text',
            ],
            'revoke_endpoint' => [
                'label' => 'Endpoint URI for revoking tokens',
                'type' => 'text',
            ],
            'logout_endpoint' => [
                'label' => 'Endpoint URI for logging out',
                'type' => 'text',
            ],
            'userinfo_endpoint' => [
                'label' => 'Endpoint URI for fetching user data',
                'type' => 'text',
            ],

            // Mapping rules
            'id_claim' => [
                'label' => 'Claim with owner id',
                'desc' => 'The name of the oidc claim that contains a unique identifier of the resource owner. Usually \'sub\' (Microsoft, Switch Edu-Id), but could be also \'id\' (Google). Check the documentation of the provider to find the proper value.',
                'type' => 'text',
            ],
            'usernames' => [
                'label' => 'Claims with potential usernames',
                'desc' => 'These claims are used to find an existing Typo3 account by its username or email. The value of each claim can be a string or an array.',
                'type' => 'text',
            ],
            'emails' => [
                'label' => 'Claims with potential email addresses',
                'type' => 'text',
            ],
            'mapping' => [
                'label' => 'Claims mapped to user properties',
                'desc' => 'The mapping of claims in the resource owner to the user table. Each line is a comment beginning with # or has the format "field = string_template", where in "string_template" each occurrence of <claim> is replaced with the value of the claim. A double slash "//" can be used to separate several templates — in such case the first template producing a non-trivial string is taken. The property "username" is special - if all templates result in trivial string, the resource owner identifier is assigned.',
                'type' => 'textarea',
            ],

            // Notes
            'description' => [
                'label' => 'Description',
                'desc' => 'A note visible to backend users only.',
                'type' => 'textarea',
            ],
        ];
    }

    /**
     * Get available LDAP servers from ig_ldap_sso_auth extension
     *
     * @param Database $db Database connection
     * @return array Array of LDAP servers with uid and name
     */
    private static function getLdapServers(Database $db): array
    {
        // Check if table exists
        $tableCheck = $db->sql(
            "SHOW TABLES LIKE '" . TABLE_T3_LDAP_SSO_AUTH_CONFIG . "'",
            ROW_REGULAR
        );

        if (empty($tableCheck)) {
            return [];
        }

        $servers = $db->sql(
            "SELECT uid, name FROM " . TABLE_T3_LDAP_SSO_AUTH_CONFIG . " WHERE deleted = 0 AND hidden = 0 ORDER BY sorting, uid",
            ROW_REGULAR
        );

        return $servers ?: [];
    }

    /**
     * Get processor field metadata for oidc_client
     * Used for FlexForms processor fields
     * Note: 'servers' field is handled dynamically in parseOidcProcessorsXml
     *
     * @return array Processor metadata organized by sheet
     */
    private static function getOidcProcessorMetadata(): array
    {
        // Note: 'servers' field is handled dynamically in parseOidcProcessorsXml
        // as individual checkbox fields with __cat grouping
        return [
            OIDC_CAT_PROCESSORS_LDAP => [
                '__category_label' => 'LDAP',
                '__category_desc' => 'Updates an OIDC resource with data from an LDAP server',
                'enabled' => ['label' => 'Enable', 'desc' => '', 'type' => 'checkbox'],
                // 'servers' is expanded to server_N fields dynamically with __cat => 'servers'
                'filter' => ['label' => 'Query filter', 'desc' => 'Values surrounded with < and > are replaced with claims in the current resource', 'type' => 'text'],
                'mapping' => ['label' => 'Property mapping', 'desc' => 'Rules to map properties to the resource', 'type' => 'textarea'],
            ],
            OIDC_CAT_PROCESSORS_QFQ => [
                '__category_label' => 'QFQ',
                '__category_desc' => 'Process resources with QFQ reports',
                'enabled' => ['label' => 'Enable', 'desc' => '', 'type' => 'checkbox'],
                'prefix' => ['label' => 'OIDC prefix', 'desc' => '', 'type' => 'text'],
                'store' => [
                    'label' => 'Output store',
                    'desc' => 'The store to which OIDC data are copied after the resource has been processed',
                    'type' => 'select',
                    'options' => ['none', 'T', 'U', 'W', 'V'],
                ],
                'report' => ['label' => 'Report', 'desc' => '', 'type' => 'textarea'],
            ],
        ];
    }

    /**
     * Parse OIDC processors FlexForms XML into inputCollectionJson format
     * Uses standalone XML parser (no TYPO3 dependencies)
     *
     * @param string $xml FlexForms XML string
     * @param array $ldapServers Optional array of available LDAP servers for options
     * @return array Parsed categories with fields
     */
    private static function parseOidcProcessorsXml(string $xml, array $ldapServers = []): array
    {
        if (empty($xml)) {
            return [];
        }

        // Parse FlexForms XML without TYPO3 dependencies
        $flexData = ExtensionSettings::parseFlexFormsXml($xml);

        if (empty($flexData)) {
            return [];
        }

        $result = [];
        $processorMeta = self::getOidcProcessorMetadata();

        // Sheet index to category mapping
        // Multiple sheet names can map to the same category
        $sheetMapping = [
            OIDC_SHEET_LDAP_SYNC => OIDC_CAT_PROCESSORS_LDAP,
            OIDC_SHEET_LDAP_PROCESSOR => OIDC_CAT_PROCESSORS_LDAP,
            OIDC_SHEET_QFQ_PROCESSOR => OIDC_CAT_PROCESSORS_QFQ,
        ];

        // FlexFormService returns flat structure per sheet
        foreach ($sheetMapping as $sheetIndex => $categoryKey) {
            if (!isset($flexData[$sheetIndex]) || !isset($processorMeta[$categoryKey])) {
                continue;
            }

            $sheetData = $flexData[$sheetIndex];
            $categoryMeta = $processorMeta[$categoryKey];

            // Initialize category if not exists
            if (!isset($result[$categoryKey])) {
                $result[$categoryKey] = [];
            }

            // Process fields from this sheet
            foreach ($sheetData as $fieldIndex => $value) {
                // Remove 'settings.' prefix
                $fieldName = preg_replace('/^settings\./', '', $fieldIndex);
                $value = (string)$value;

                // Skip legacy prefixed fields (ldap_*, qfq_*) - they duplicate the non-prefixed fields
                if (preg_match('/^(ldap|qfq)_/', $fieldName)) {
                    continue;
                }

                // Special handling for 'servers' field: expand to individual boolean fields
                if ($fieldName === 'servers' && $categoryKey === OIDC_CAT_PROCESSORS_LDAP) {
                    $selectedServers = array_filter(array_map('trim', explode(',', $value)));

                    // Create a boolean field for each available LDAP server
                    foreach ($ldapServers as $server) {
                        $serverUid = $server[API_RESULT_KEY_UID];
                        $serverName = $server[API_RESULT_KEY_NAME];
                        $isSelected = in_array((string)$serverUid, $selectedServers);

                        $result[$categoryKey]['server_' . $serverUid] = [
                            ICJ_KEY_VALUE => $isSelected ? 1 : 0,
                            ICJ_KEY_LABEL => $serverName,
                            ICJ_KEY_TYPE => 'checkbox',
                            ICJ_KEY_CAT => 'servers',
                        ];
                    }
                    continue;
                }

                // Get field metadata first to determine type
                $meta = $categoryMeta[$fieldName] ?? [];
                $fieldType = $meta[META_KEY_TYPE] ?? 'text';

                // Convert value based on type (checkbox/number need int, not string)
                if ($fieldType === 'checkbox' || $fieldType === 'number') {
                    $value = (int)$value;
                }

                $fieldData = [ICJ_KEY_VALUE => $value];

                // Add field metadata
                if (!empty($meta)) {
                    if (!empty($meta[META_KEY_LABEL])) {
                        $fieldData[ICJ_KEY_LABEL] = $meta[META_KEY_LABEL];
                    }
                    if (!empty($meta[META_KEY_DESC])) {
                        $fieldData[ICJ_KEY_DESC] = $meta[META_KEY_DESC];
                    }
                    if (!empty($fieldType)) {
                        $fieldData[ICJ_KEY_TYPE] = $fieldType;
                    }
                    if (!empty($meta[META_KEY_OPTIONS])) {
                        $fieldData[ICJ_KEY_OPTIONS] = $meta[META_KEY_OPTIONS];
                    }
                }

                $result[$categoryKey][$fieldName] = $fieldData;
            }
        }

        // Always add server checkboxes for processors_ldap, even if no servers field in XML
        // This ensures new providers get server options after sync
        if (!empty($ldapServers) && isset($result[OIDC_CAT_PROCESSORS_LDAP])) {
            $hasServerFields = false;
            foreach ($result[OIDC_CAT_PROCESSORS_LDAP] as $fieldName => $fieldData) {
                if (str_starts_with($fieldName, 'server_')) {
                    $hasServerFields = true;
                    break;
                }
            }

            // Add server fields if not already present
            if (!$hasServerFields) {
                foreach ($ldapServers as $server) {
                    $serverUid = $server[API_RESULT_KEY_UID];
                    $serverName = $server[API_RESULT_KEY_NAME];

                    $result[OIDC_CAT_PROCESSORS_LDAP]['server_' . $serverUid] = [
                        ICJ_KEY_VALUE => 0,
                        ICJ_KEY_LABEL => $serverName,
                        ICJ_KEY_TYPE => 'checkbox',
                        ICJ_KEY_CAT => 'servers',
                    ];
                }
            }
        }

        return $result;
    }

    /**
     * Build OIDC processors FlexForms XML from inputCollectionJson format
     *
     * @param array $processorData Processor categories from input
     * @param string|null $existingXml Existing XML to merge with (preserve unmodified sheets)
     * @return string FlexForms XML string
     */
    public static function buildOidcProcessorsXml(array $processorData, ?string $existingXml = null): string
    {
        // Category to sheet mapping (simplified to 2 categories matching UI tabs)
        $categoryToSheet = [
            OIDC_CAT_PROCESSORS_LDAP => OIDC_SHEET_LDAP_PROCESSOR,
            OIDC_CAT_PROCESSORS_QFQ => OIDC_SHEET_QFQ_PROCESSOR,
        ];

        // Parse existing XML using standalone parser (no TYPO3 dependencies)
        $sheets = [];
        if (!empty($existingXml)) {
            $flexData = ExtensionSettings::parseFlexFormsXml($existingXml);

            // Convert parsed output to our internal format with 'settings.' prefix
            foreach ($flexData as $sheetIndex => $sheetData) {
                if (is_array($sheetData)) {
                    $sheets[$sheetIndex] = [];
                    foreach ($sheetData as $fieldName => $value) {
                        // Add 'settings.' prefix if not present for XML building
                        $fieldIndex = (str_starts_with($fieldName, 'settings.')) ? $fieldName : 'settings.' . $fieldName;
                        $sheets[$sheetIndex][$fieldIndex] = (string)$value;
                    }
                }
            }
        }

        // Get metadata for correct field ordering
        $processorMeta = self::getOidcProcessorMetadata();

        // Update sheets with new data (iterate in metadata-defined order)
        foreach ($processorData as $category => $fields) {
            $sheetIndex = $categoryToSheet[$category] ?? null;
            if (!$sheetIndex) {
                continue;
            }

            if (!isset($sheets[$sheetIndex])) {
                $sheets[$sheetIndex] = [];
            }

            // Collect server_* fields to collapse into 'servers' string
            $selectedServers = [];
            $hasServerFields = false;

            foreach ($fields as $fieldName => $fieldData) {
                if (preg_match('/^server_(\d+)$/', $fieldName, $matches)) {
                    $hasServerFields = true;
                    $serverUid = $matches[1];
                    $value = is_array($fieldData) ? ($fieldData['value'] ?? '0') : $fieldData;
                    if ($value === '1' || $value === 1 || $value === true) {
                        $selectedServers[] = $serverUid;
                    }
                }
            }

            // Write fields in metadata-defined order (ensures consistent field order)
            $categoryMeta = $processorMeta[$category] ?? [];
            foreach ($categoryMeta as $metaFieldName => $meta) {
                // Skip category metadata
                if (str_starts_with($metaFieldName, '__')) {
                    continue;
                }

                // Check if this field exists in input data
                if (isset($fields[$metaFieldName])) {
                    $fieldData = $fields[$metaFieldName];
                    $value = is_array($fieldData) ? ($fieldData['value'] ?? '') : $fieldData;
                    $sheets[$sheetIndex]['settings.' . $metaFieldName] = $value;
                }
            }

            // Write servers field if server_* fields were present
            if ($hasServerFields) {
                $sheets[$sheetIndex]['settings.servers'] = implode(',', $selectedServers);
            }
        }

        // Build XML
        $xml = '<?xml version="1.0" encoding="utf-8" standalone="yes" ?>' . "\n";
        $xml .= "<T3FlexForms>\n    <data>\n";

        foreach ($sheets as $sheetIndex => $fields) {
            $xml .= '        <sheet index="' . htmlspecialchars($sheetIndex) . '">' . "\n";
            $xml .= '            <language index="lDEF">' . "\n";

            foreach ($fields as $fieldIndex => $value) {
                $xml .= '                <field index="' . htmlspecialchars($fieldIndex) . '">' . "\n";
                $xml .= '                    <value index="vDEF">' . htmlspecialchars($value, ENT_QUOTES | ENT_XML1) . '</value>' . "\n";
                $xml .= '                </field>' . "\n";
            }

            $xml .= '            </language>' . "\n";
            $xml .= '        </sheet>' . "\n";
        }

        $xml .= "    </data>\n</T3FlexForms>";

        return $xml;
    }

    /**
     * Flatten inputCollectionJson format to flat array for oidc provider
     * Input: {"general": {"title": {"value": "UZH SSO"}}, "client": {"client_id": {"value": "abc"}}}
     * Output: {"title": "UZH SSO", "client_id": "abc"}
     * Also converts boolean fields back to bitmask for backend/frontend
     * Processor categories (processors_*) are extracted separately
     *
     * @param array $data Provider data in inputCollectionJson format
     * @return array Flat key-value array with special '__processors' key if processor data exists
     */
    public static function flattenOidcProviderValues(array $data): array
    {
        $flat = [];
        $processors = [];

        foreach ($data as $categoryOrField => $content) {
            // Check if this is a processor category
            if (str_starts_with($categoryOrField, 'processors_')) {
                $processors[$categoryOrField] = $content;
                continue;
            }

            if (!is_array($content)) {
                // Direct flat value
                $flat[$categoryOrField] = $content;
                continue;
            }

            // Check if this is a direct {"value": "..."} format
            if (array_key_exists('value', $content)) {
                $flat[$categoryOrField] = $content['value'];
                continue;
            }

            // This is a category containing fields
            foreach ($content as $field => $fieldData) {
                // Skip metadata fields
                if (str_starts_with($field, '__')) {
                    continue;
                }
                if (is_array($fieldData) && array_key_exists('value', $fieldData)) {
                    $flat[$field] = $fieldData['value'];
                } elseif (!is_array($fieldData)) {
                    $flat[$field] = $fieldData;
                }
            }
        }

        // Convert boolean fields back to bitmask for backend/frontend
        $flat = self::collapseBitmaskFields($flat);

        // Store processors data for later XML conversion
        if (!empty($processors)) {
            $flat['__processors'] = $processors;
        }

        return $flat;
    }

    /**
     * Collapse individual boolean fields back to bitmask for backend/frontend
     * Bitmask: 1=Enabled, 2=Create users, 4=Unlock users
     *
     * @param array $flat Flat key-value array
     * @return array Array with bitmask fields
     */
    public static function collapseBitmaskFields(array $flat): array
    {
        // Collapse backend booleans to bitmask
        $hasBackendFields = isset($flat['backend_enabled']) || isset($flat['backend_create_users']) || isset($flat['backend_unlock_users']);
        if ($hasBackendFields) {
            $backend = 0;
            if (!empty($flat['backend_enabled'])) $backend |= 1;
            if (!empty($flat['backend_create_users'])) $backend |= 2;
            if (!empty($flat['backend_unlock_users'])) $backend |= 4;
            $flat['backend'] = $backend;
            unset($flat['backend_enabled'], $flat['backend_create_users'], $flat['backend_unlock_users']);
        }

        // Collapse frontend booleans to bitmask
        $hasFrontendFields = isset($flat['frontend_enabled']) || isset($flat['frontend_create_users']) || isset($flat['frontend_unlock_users']);
        if ($hasFrontendFields) {
            $frontend = 0;
            if (!empty($flat['frontend_enabled'])) $frontend |= 1;
            if (!empty($flat['frontend_create_users'])) $frontend |= 2;
            if (!empty($flat['frontend_unlock_users'])) $frontend |= 4;
            $flat['frontend'] = $frontend;
            unset($flat['frontend_enabled'], $flat['frontend_create_users'], $flat['frontend_unlock_users']);
        }

        return $flat;
    }

    /**
     * Set oidc_client provider configurations
     * Accepts inputCollectionJson format: {"providers": {"name": {"category": {"field": {"value": "..."}}}}}
     *
     * @param array $config Provider configurations
     * @param Database $db Database connection
     * @return array Results
     */
    private static function setOidcClientProviders(array $config, Database $db): array
    {
        if (!self::isOidcClientInstalled()) {
            return [OIDC_KEY_ERROR => EXT_KEY_OIDC_CLIENT . ' extension not installed'];
        }

        // Check if table exists
        $tableCheck = $db->sql(
            "SHOW TABLES LIKE '" . TABLE_T3_OIDC_CLIENT_CONFIG . "'",
            ROW_REGULAR
        );

        if (empty($tableCheck)) {
            return [OIDC_KEY_ERROR => TABLE_T3_OIDC_CLIENT_CONFIG . ' table does not exist'];
        }

        $providers = $config[OIDC_KEY_PROVIDERS] ?? [];
        $results = [];

        // Define allowed fields for OIDC client config table
        $allowedFields = [
            'auth_endpoint', 'token_endpoint', 'userinfo_endpoint', 'logout_endpoint', 'revoke_endpoint',
            'callback_forwarding', 'lang_parameter',
            'client_id', 'client_secret', 'client_scopes', 'usernames', 'emails', 'mapping', 'id_claim',
            'backend', 'frontend', 'mfa', 'be_groups', 'fe_groups',
            'name', 'title', 'description', 'disable', 'sorting', 'processors',
        ];

        // inputCollectionJson format: {"providers": {"microsoftuzh": {"general": {"title": {"value": "..."}}}}}
        foreach ($providers as $name => $providerData) {
            // Handle double-encoded JSON (string instead of array)
            if (is_string($providerData)) {
                $providerData = json_decode($providerData, true);
                if (json_last_error() !== JSON_ERROR_NONE || !is_array($providerData)) {
                    // Skip invalid provider data
                    continue;
                }
            }
            // Convert inputCollectionJson format to flat
            $providerData = self::flattenOidcProviderValues($providerData);
            $providerData['name'] = $name;

            // Extract processors data if present
            $processorsData = null;
            if (isset($providerData['__processors'])) {
                $processorsData = $providerData['__processors'];
                unset($providerData['__processors']);
            }

            $result = [
                API_RESULT_KEY_NAME => $name,
                API_RESULT_KEY_ACTION => null,
                API_RESULT_KEY_CHANGES => [],
            ];

            // Check if provider exists (active)
            $existing = $db->sql(
                "SELECT * FROM " . TABLE_T3_OIDC_CLIENT_CONFIG . " WHERE name = ? AND deleted = 0",
                ROW_EXPECT_0_1,
                [$name]
            );

            // If no active provider, check for deleted one (name is unique key)
            $deletedExisting = null;
            if (empty($existing)) {
                $deletedExisting = $db->sql(
                    "SELECT * FROM " . TABLE_T3_OIDC_CLIENT_CONFIG . " WHERE name = ? AND deleted = 1",
                    ROW_EXPECT_0_1,
                    [$name]
                );
            }

            // Handle delete action
            if (!empty($providerData['delete'])) {
                if (!empty($existing)) {
                    $db->sql(
                        "UPDATE " . TABLE_T3_OIDC_CLIENT_CONFIG . " SET deleted = 1, tstamp = ? WHERE uid = ?",
                        ROW_REGULAR,
                        [time(), $existing[API_RESULT_KEY_UID]]
                    );
                    $result[API_RESULT_KEY_ACTION] = 'deleted';
                    $result[API_RESULT_KEY_UID] = $existing[API_RESULT_KEY_UID];
                } else {
                    $result[API_RESULT_KEY_ACTION] = 'not_found';
                }
                $results[] = $result;
                continue;
            }

            // Filter to allowed fields only
            $fields = array_intersect_key($providerData, array_flip($allowedFields));

            // Convert mapping field from text to JSON format (OIDC Client stores as JSON)
            if (isset($fields['mapping']) && is_string($fields['mapping']) && !str_starts_with(trim($fields['mapping']), '{')) {
                $fields['mapping'] = self::convertMappingTextToJson($fields['mapping']);
            }

            // Handle processors XML if processor data was provided
            if ($processorsData !== null) {
                $existingXml = $existing['processors'] ?? null;
                $newXml = self::buildOidcProcessorsXml($processorsData, $existingXml);
                $fields['processors'] = $newXml;
            }

            if (!empty($existing)) {
                // Update existing provider
                $uid = $existing[API_RESULT_KEY_UID];
                $result[API_RESULT_KEY_UID] = $uid;
                $result[API_RESULT_KEY_ACTION] = 'updated';

                $updates = [];
                $params = [];

                foreach ($fields as $field => $value) {
                    // Skip if value hasn't changed
                    if ($existing[$field] == $value) {
                        continue;
                    }

                    // Don't log the actual secret value
                    $logValue = ($field === 'client_secret') ? '***' : $value;
                    $logOldValue = ($field === 'client_secret') ? '***' : $existing[$field];

                    $result[API_RESULT_KEY_CHANGES][] = [
                        API_CHANGE_KEY_FIELD => $field,
                        API_CHANGE_KEY_OLD => $logOldValue,
                        API_CHANGE_KEY_NEW => $logValue,
                    ];

                    $updates[] = "$field = ?";
                    $params[] = $value;
                }

                if (!empty($updates)) {
                    $updates[] = "tstamp = ?";
                    $params[] = time();
                    $params[] = $uid;

                    $db->sql(
                        "UPDATE " . TABLE_T3_OIDC_CLIENT_CONFIG . " SET " . implode(', ', $updates) . " WHERE uid = ?",
                        ROW_REGULAR,
                        $params
                    );
                }
            } elseif (!empty($deletedExisting)) {
                // Reactivate deleted provider (name is unique key, can't INSERT)
                $uid = $deletedExisting[API_RESULT_KEY_UID];
                $result[API_RESULT_KEY_UID] = $uid;
                $result[API_RESULT_KEY_ACTION] = 'reactivated';

                // Build UPDATE with all fields + deleted=0
                $updates = [];
                $params = [];

                foreach ($fields as $field => $value) {
                    $updates[] = "$field = ?";
                    $params[] = $value;

                    $logValue = ($field === 'client_secret') ? '***' : $value;
                    $result[API_RESULT_KEY_CHANGES][] = [
                        API_CHANGE_KEY_FIELD => $field,
                        API_CHANGE_KEY_OLD => null,
                        API_CHANGE_KEY_NEW => $logValue,
                    ];
                }

                $updates[] = "deleted = 0";
                $updates[] = "tstamp = ?";
                $params[] = time();
                $params[] = $uid;

                $db->sql(
                    "UPDATE " . TABLE_T3_OIDC_CLIENT_CONFIG . " SET " . implode(', ', $updates) . " WHERE uid = ?",
                    ROW_REGULAR,
                    $params
                );
            } else {
                // Create new provider
                $result[API_RESULT_KEY_ACTION] = 'created';

                $fields['tstamp'] = time();
                $fields['crdate'] = time();

                $fieldNames = array_keys($fields);
                $placeholders = array_fill(0, count($fields), '?');
                $values = array_values($fields);

                $db->sql(
                    "INSERT INTO " . TABLE_T3_OIDC_CLIENT_CONFIG . " (" . implode(', ', $fieldNames) . ") VALUES (" . implode(', ', $placeholders) . ")",
                    ROW_REGULAR,
                    $values
                );

                $result[API_RESULT_KEY_UID] = $db->getLastInsertId();
                $result[API_RESULT_KEY_CHANGES] = array_map(function ($field, $value) {
                    return [
                        API_CHANGE_KEY_FIELD => $field,
                        API_CHANGE_KEY_OLD => null,
                        API_CHANGE_KEY_NEW => ($field === 'client_secret') ? '***' : $value,
                    ];
                }, $fieldNames, $values);
            }

            $results[] = $result;
        }

        return [
            OIDC_KEY_PROCESSED => count($results),
            OIDC_KEY_RESULTS => $results,
        ];
    }
}
