<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 4/8/17
 * Time: 6:53 PM
 */

namespace IMATHUZH\Qfq\Core\Store;


use IMATHUZH\OidcClient\Service\UserStorage;
use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Exception\Thrower;
use IMATHUZH\Qfq\Core\Helper\Path;
use IMATHUZH\Qfq\Core\Typo3\T3Handler;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;


/**
 * Class T3Info
 * @package qfq
 */
class T3Info {

    /**
     * Collect some global T3 variables and return them as array.
     *
     * @return array
     *
     * @todo v12 migration: access feUser via $request->getAttribute('frontend.user')
     */
    public static function getVars() {

        $t3vars = array();

        $t3vars[TYPO3_FE_USER] = isset($GLOBALS["TSFE"]->fe_user->user["username"]) ? $GLOBALS["TSFE"]->fe_user->user["username"] : '';
        $t3vars[TYPO3_FE_USER_EMAIL] = isset($GLOBALS["TSFE"]->fe_user->user["email"]) ? $GLOBALS["TSFE"]->fe_user->user["email"] : '';

        $t3vars[TYPO3_FE_USER_UID] = isset($GLOBALS["TSFE"]->fe_user->user["uid"]) ? $GLOBALS["TSFE"]->fe_user->user["uid"] : '';

        $t3vars[TYPO3_FE_USER_GROUP] = isset($GLOBALS["TSFE"]->fe_user->user["usergroup"]) ? $GLOBALS["TSFE"]->fe_user->user["usergroup"] : '';

        // Will be set later
        $t3vars[TYPO3_TT_CONTENT_UID] = 'not set yet';

        $t3vars[TYPO3_PAGE_ID] = isset($GLOBALS["TSFE"]->id) ? $GLOBALS["TSFE"]->id : '';

        $t3vars[TYPO3_PAGE_ALIAS] = empty($GLOBALS["TSFE"]->page["alias"]) ? $t3vars[TYPO3_PAGE_ID] : $GLOBALS["TSFE"]->page["alias"];

        if (T3Handler::useSlugsInsteadOfPageAlias()) {
            $t3vars[TYPO3_PAGE_SLUG] = empty($GLOBALS["TSFE"]->page["slug"]) ? '/' : $GLOBALS["TSFE"]->page["slug"];
        } else {
            $t3vars[TYPO3_PAGE_SLUG] = $t3vars[TYPO3_PAGE_ALIAS]; // the Form Editor report (formEditor.qfqr) depends on this for T3 older than version 9.
        }

        $t3vars[TYPO3_PAGE_TITLE] = isset($GLOBALS["TSFE"]->page["title"]) ? $GLOBALS["TSFE"]->page["title"] : '';

        $t3vars[TYPO3_PAGE_TYPE] = isset($GLOBALS["TSFE"]->type) ? $GLOBALS["TSFE"]->type : '';

        $t3vars[TYPO3_PAGE_LANGUAGE] = self::getLanguageId();

        $t3vars[TYPO3_PAGE_LANGUAGE_PATH] = self::getLanguagePath($t3vars[TYPO3_PAGE_LANGUAGE]);

        $t3vars[TYPO3_BE_USER_LOGGED_IN] = self::beUserLoggedIn() ? 'yes' : 'no';

        $t3vars[TYPO3_BE_USER] = isset($GLOBALS["BE_USER"]->user["username"]) ? $GLOBALS["BE_USER"]->user["username"] : '';
        $t3vars[TYPO3_BE_USER_EMAIL] = isset($GLOBALS["BE_USER"]->user["email"]) ? $GLOBALS["BE_USER"]->user["email"] : '';
        $t3vars[TYPO3_BE_USER_UID] = isset($GLOBALS["BE_USER"]->user["uid"]) ? $GLOBALS["BE_USER"]->user["uid"] : '';

        $t3vars[TYPO3_PAGE_DESCRIPTION] = $GLOBALS["TSFE"]->page[T3DATA_DESCRIPTION] ?? '';

        $t3vars[TYPO3_PAGE_ABSTRACT] = $GLOBALS["TSFE"]->page[T3DATA_ABSTRACT] ?? '';

        $t3vars[TYPO3_PAGE_KEYWORDS] = $GLOBALS["TSFE"]->page[T3DATA_KEYWORDS] ?? '';

        if (defined('TYPO3_version')) {
            $t3vars[TYPO3_VERSION] = TYPO3_version;
        }

        /***** oidc data ******/

        if (class_exists(UserStorage::class)) {
            $oidcStorage = (new UserStorage($GLOBALS["TSFE"]->fe_user))->get() ?? [];
        } else {
            $oidcStorage = [];
        }

        $t3vars[TYPO3_OIDC_PROVIDER] = $oidcStorage['provider'] ?? '';
        $t3vars[TYPO3_OIDC_RESOURCE_ID] = $oidcStorage['resource_id'] ?? '';
        $t3vars[TYPO3_OIDC_SUB] = $oidcStorage['sub'] ?? '';
        $t3vars[TYPO3_OIDC_EMAIL] = $oidcStorage['email'] ?? '';
        if (isset($oidcStorage['email_verified'])) {
            $t3vars[TYPO3_OIDC_EMAIL_VERIFIED] = ($oidcStorage['email_verified'] === true) ? 'true' : 'false';
        } else {
            $t3vars[TYPO3_OIDC_EMAIL_VERIFIED] = 'null';
        }
        $t3vars[TYPO3_OIDC_GIVEN_NAME] = $oidcStorage['given_name'] ?? '';
        $t3vars[TYPO3_OIDC_FAMILY_NAME] = $oidcStorage['family_name'] ?? '';

        /***** oidc data end ******/


        return $t3vars;
    }

    /**
     * Returns the page language uid. This is only possible if QFQ is called via T3, not via API.
     *
     * @return string
     */
    public static function getLanguageId() {
        if (class_exists('TYPO3\CMS\Core\Context\Context')) {
            // Typo3 version >=9
            $context = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Context\Context::class);
            $languageAspect = $context->getAspect('language');
            // (previously known as TSFE->sys_language_uid)
            return $languageAspect->getId();
        } elseif (isset($GLOBALS["TSFE"]->sys_language_uid)) {
            // Typo3 version <=8
            return $GLOBALS["TSFE"]->sys_language_uid;
        }
        return '';
    }

    /**
     * Returns the base url with language path. This is only possible if QFQ is called via T3, not via API.
     *
     * @return string
     * @throws \UserReportException
     */
    public static function getBaseUrlLang() {
        if (!T3Handler::isTypo3Loaded()) {
            return null;
        }

        $lid = self::getLanguageId();
        if ($lid !== '' && is_numeric($lid) && T3Handler::typo3VersionGreaterEqual9()) {
            $lid = intval($lid);
            try {
                // In an API call Typo3 might have been loaded manually by QFQ at some earlier point. But the following line does not work in that case.
                $sf = new \TYPO3\CMS\Core\Site\SiteFinder();
            } catch (\Throwable $e) {
                return null;
            }
            $allSites = $sf->getAllSites();
            if (count($allSites) < 1) {
                return null;
            } else {
                $site = self::getSiteConfiguration($sf);
                $lang = $site->getLanguageById($lid);
                $base = $lang->getBase();
                return (string)$base;
            }
        }
        return null;
    }

    /**
     * Check if there is a Typo3 beUser is logged in. This is only possible if QFQ is called via T3, not via API
     *
     * @return bool  true: current Browser session is a logged in BE User.
     */
    public static function beUserLoggedIn() {

        if (T3Handler::isTypo3Loaded() && T3Handler::typo3VersionGreaterEqual9()) {
            // Typo3 version >=9
            $context = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Context\Context::class);
            return $context->getPropertyFromAspect('backend.user', 'isLoggedIn');
        } elseif (!empty($GLOBALS["TSFE"]->beUserLogin) && $GLOBALS["TSFE"]->beUserLogin === true) {
            // Typo3 version <=8
            return true;
        }
        return false;
    }

    /**
     * Returns the language path part. Helpful for forwardPage handling of forms in T3 >=V10
     *
     * @return string
     */
    public static function getLanguagePath($languageId): string {
        $languagePath = '';
        if (T3Handler::typo3VersionGreaterEqual10() && isset($GLOBALS['TYPO3_REQUEST'])) {
            $siteIdentifier = $GLOBALS['TYPO3_REQUEST']->getAttribute('site')->getIdentifier();
            $siteConfiguration = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Site\SiteFinder::class)->getSiteByIdentifier($siteIdentifier)->getConfiguration();
            $languages = $siteConfiguration['languages'];
            $actualLanguage = $languages[$languageId]['base'] ?? '';
            $languagePath = trim($actualLanguage, '/');
        }

        return $languagePath;
    }

    /**
     * Returns the site configuration from current page. Used for T3 >=V10.
     *
     * object $sf
     * @return object
     */
    public static function getSiteConfiguration($sf): object {
        if (T3Handler::typo3VersionGreaterEqual10() && isset($GLOBALS['TYPO3_REQUEST'])) {
            $siteIdentifier = $GLOBALS['TYPO3_REQUEST']->getAttribute('site')->getIdentifier();
            $actualSite = $sf->getSiteByIdentifier($siteIdentifier);
        } else {
            $allSites = $sf->getAllSites();
            $actualSite = reset($allSites);
        }

        return $actualSite;
    }

    /**
     * Get typo 3 extension informations
     *
     *
     * @return array
     */
    public static function getAllExtensionsInfo(): array {
        $packageStatesPath = Path::absoluteTypo3Conf('PackageStates.php');

        if (!file_exists($packageStatesPath)) {
            throw new \Exception('PackageStates.php does not exist at the specified path.');
        }

        $packageStates = include $packageStatesPath;

        $extensions = [];
        if (isset($packageStates['packages'])) {
            foreach ($packageStates['packages'] as $key => $package) {
                $extensions[$key] = $package['packagePath'];
            }
        }

        return $extensions;
    }

    /**
     * Get all backend users with admin and maintainer status
     *
     * @param Database $db
     * @param array $systemMaintainers
     * @return array
     */
    public static function getAllBackendUsers(Database $db, array $systemMaintainers = []): array
    {
        $users = $db->sql(
            "SELECT uid, username, admin, email, realName, disable FROM be_users WHERE deleted = 0",
            ROW_REGULAR
        );

        return array_map(function ($user) use ($systemMaintainers) {
            $uid = (int)$user['uid'];
            return [
                'uid' => $uid,
                'username' => $user['username'],
                'realName' => $user['realName'],
                'email' => $user['email'],
                'isAdmin' => (bool)$user['admin'],
                'isMaintainer' => in_array($uid, $systemMaintainers, true),
                'isDisabled' => (bool)$user['disable'],
            ];
        }, $users);
    }

    /**
     * Ensure backend users have correct admin/maintainer/disabled status
     *
     * @param array|string $config JSON string or array with username => settings mapping
     * @param Database $db
     * @param array $systemMaintainers Current system maintainers (will be updated by reference)
     * @return array
     */
    public static function ensureAdminMaintainers(array|string $config, Database $db, array &$systemMaintainers): 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());
            }
        }

        $originalMaintainers = $systemMaintainers;
        $results = [];

        foreach ($config as $username => $settings) {
            $isAdmin = (bool)($settings['isAdmin'] ?? false);
            $isMaintainer = (bool)($settings['isMaintainer'] ?? false);
            $isDisabled = (bool)($settings['isDisabled'] ?? false);
            $delete = (bool)($settings['delete'] ?? false);

            $result = [
                'username' => $username,
                'uid' => null,
                'created' => false,
                'changes' => [],
            ];

            // Check if user exists
            $existing = $db->sql(
                "SELECT uid, admin, disable, deleted FROM be_users WHERE username = ?  AND deleted = ?",
                ROW_EXPECT_0_1,
                [$username, 0]
            );

            if (!empty($existing)) {
                $uid = (int)$existing[API_RESULT_KEY_UID];
                $result[API_RESULT_KEY_UID] = $uid;
                $updates = [];

                // Check admin flag
                if ((bool)$existing['admin'] !== $isAdmin) {
                    $updates[] = 'admin = ' . ($isAdmin ? 1 : 0);
                    $result[API_RESULT_KEY_CHANGES][] = $isAdmin ? 'admin granted' : 'admin revoked';
                }

                // Check disable flag
                if ((bool)$existing['disable'] !== $isDisabled) {
                    $updates[] = 'disable = ' . ($isDisabled ? 1 : 0);
                    $result[API_RESULT_KEY_CHANGES][] = $isDisabled ? 'disabled' : 'enabled';
                }

                if ((bool)$existing['deleted'] !== $delete) {
                    $updates[] = 'deleted = ' . ($delete ? 1 : 0);
                    $result[API_RESULT_KEY_CHANGES][] = $delete ? 'deleted' : 'revoke delete';
                }

                if (!empty($updates)) {
                    $updates[] = 'tstamp = ' . time();
                    $db->sql("UPDATE be_users SET " . implode(', ', $updates) . " WHERE uid = ?", ROW_REGULAR, [$uid]);
                }
            } else if (!$delete) {
                // Create new user
                $db->sql(
                    "INSERT INTO be_users (username, admin, disable, password, tstamp, crdate) VALUES (?, ?, ?, '', ?, ?)",
                    ROW_REGULAR,
                    [$username, $isAdmin ? 1 : 0, $isDisabled ? 1 : 0, time(), time()]
                );
                $uid = $db->getLastInsertId();
                $result[API_RESULT_KEY_UID] = $uid;
                $result['created'] = true;
                $result[API_RESULT_KEY_CHANGES][] = 'user created';
            }

            // Handle maintainer status
            $isCurrentlyMaintainer = in_array($uid, $systemMaintainers, true);

            if ($isMaintainer && !$isCurrentlyMaintainer) {
                $systemMaintainers[] = $uid;
                $result[API_RESULT_KEY_CHANGES][] = 'maintainer added';
            } elseif (!$isMaintainer && $isCurrentlyMaintainer) {
                $systemMaintainers = array_values(array_filter($systemMaintainers, fn($id) => $id !== $uid));
                $result[API_RESULT_KEY_CHANGES][] = 'maintainer removed';
            }

            $results[] = $result;
        }

        // Check if maintainers changed
        $maintainersChanged = $systemMaintainers !== $originalMaintainers;

        // Write to config if changed
        if ($maintainersChanged) {
            self::setSystemMaintainersToConfig(array_values($systemMaintainers));
        }

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

    /**
     * Write systemMaintainers to LocalConfiguration.php
     *
     * @param array $maintainers
     */
    private static function setSystemMaintainersToConfig(array $maintainers): void
    {
        $localConfPath = Path::absoluteTypo3Conf('LocalConfiguration.php');
        if (!file_exists($localConfPath)) {
            return;
        }

        $config = include $localConfPath;
        $config['SYS']['systemMaintainers'] = $maintainers;

        $content = "<?php\nreturn " . var_export($config, true) . ";\n";
        file_put_contents($localConfPath, $content);

        // Clear TYPO3 system cache if available
        self::clearSystemCache();
    }

    /**
     * Clear TYPO3 system cache
     */
    private static function clearSystemCache(): void
    {
        $possiblePaths = [
            Path::absoluteTypo3Conf('../typo3temp/var/cache'), // Upgrade or legacy installations
            Path::absoluteTypo3Conf('../var/cache'),           // Fresh TYPO3 >= 11
        ];

        foreach ($possiblePaths as $cacheDir) {
            if (is_dir($cacheDir)) {
                $codeCacheDir = $cacheDir . '/code/cache_core';
                if (is_dir($codeCacheDir)) {
                    array_map('unlink', glob($codeCacheDir . '/*') ?: []);
                }
            }
        }
    }

    /**
     * Get active frontend users information (count and activity timestamps)
     *
     * @param Database $db
     * @return array
     */
    public static function getActiveFrontendUsersInfo(Database $db): array
    {
        $result = $db->sql(
            "SELECT COUNT(DISTINCT ses_userid) as count, MIN(ses_tstamp) as firstTimestamp, MAX(ses_tstamp) as lastTimestamp FROM fe_sessions WHERE ses_userid > 0",
            ROW_EXPECT_0_1
        );

        return [
            'activeCount' => (int)($result['count'] ?? 0),
            'firstActiveDatetime' => $result['firstTimestamp'] ? date('Y-m-d H:i:s', $result['firstTimestamp']) : null,
            'lastActiveDatetime' => $result['lastTimestamp'] ? date('Y-m-d H:i:s', $result['lastTimestamp']) : null
        ];
    }

    /**
     * Get extension settings for all extensions that have ext_conf_template.txt
     * Returns template content and current values in inputCollectionJson format
     *
     * @param array $t3Config TYPO3 configuration array (from LocalConfiguration.php)
     * @return array [extKey => ['template' => string, 'values' => json]]
     */
    public static function getExtensionSettings(array $t3Config): array
    {
        $extensions = self::getAllExtensionsInfo();
        $result = [];

        // Get extension configurations from TYPO3 config
        $extConfigs = $t3Config['EXTENSIONS'] ?? [];

        foreach ($extensions as $extKey => $packagePath) {
            $templatePath = Path::absoluteApp() . '/' . $packagePath . 'ext_conf_template.txt';

            if (!file_exists($templatePath)) {
                continue;
            }

            $templateContent = file_get_contents($templatePath);
            if ($templateContent === false) {
                continue;
            }

            // Get current values for this extension
            $currentValues = $extConfigs[$extKey] ?? [];

            // Parse template and build values array
            $values = self::parseExtConfTemplate($templateContent, $currentValues);

            // Resolve LLL:EXT: references in template content
            $resolvedTemplate = self::resolveLLLReferences($templateContent);

            $result[$extKey] = [
                'template' => $resolvedTemplate,
                'values' => $values,
            ];
        }

        return $result;
    }

    /**
     * Resolve all LLL:EXT: language references in a string
     * Replaces LLL:EXT:extension/path/file.xlf:key with the actual translated text
     *
     * @param string $content Content containing LLL:EXT: references
     * @return string Content with resolved references
     */
    private static function resolveLLLReferences(string $content): string
    {
        // Cache for loaded XLF files to avoid re-reading
        static $xlfCache = [];

        // Pattern to match LLL:EXT:extension/path/to/file.xlf:translationKey
        $pattern = '/LLL:EXT:([^:]+):([a-zA-Z0-9_.\-]+)/';

        return preg_replace_callback($pattern, function ($matches) use (&$xlfCache) {
            $filePath = $matches[1]; // e.g., "oidc_client/Resources/Private/Language/locallang.xlf"
            $translationKey = $matches[2]; // e.g., "settings.oidcScopes"

            // Build full path to XLF file
            $fullPath = Path::absoluteApp() . '/typo3conf/ext/' . $filePath;

            // Check if file exists
            if (!file_exists($fullPath)) {
                // Try alternative path for system extensions
                $fullPath = Path::absoluteApp() . '/typo3/sysext/' . $filePath;
                if (!file_exists($fullPath)) {
                    return $matches[0]; // Return original if file not found
                }
            }

            // Load XLF file from cache or parse it
            if (!isset($xlfCache[$fullPath])) {
                $xlfCache[$fullPath] = self::parseXlfFile($fullPath);
            }

            // Return translated value or original reference if not found
            return $xlfCache[$fullPath][$translationKey] ?? $matches[0];
        }, $content);
    }

    /**
     * Parse an XLF (XLIFF) file and return translations as key => value array
     *
     * @param string $filePath Path to the XLF file
     * @return array Translations as [key => value]
     */
    private static function parseXlfFile(string $filePath): array
    {
        $translations = [];

        $content = file_get_contents($filePath);
        if ($content === false) {
            return $translations;
        }

        // Suppress XML errors and handle them gracefully
        libxml_use_internal_errors(true);
        $xml = simplexml_load_string($content);
        libxml_clear_errors();

        if ($xml === false) {
            return $translations;
        }

        // Handle XLIFF 1.2 format (most common in TYPO3)
        // Structure: xliff/file/body/trans-unit[@id]/source
        foreach ($xml->file->body->{'trans-unit'} ?? [] as $unit) {
            $id = (string)($unit['id'] ?? '');
            $source = (string)($unit->source ?? '');

            if ($id !== '' && $source !== '') {
                $translations[$id] = $source;
            }
        }

        return $translations;
    }

    /**
     * Parse TYPO3 FlexForms XML without TYPO3 dependencies
     * Returns array structure: [sheetName => [fieldName => value, ...], ...]
     *
     * FlexForms XML structure:
     * <T3FlexForms>
     *   <data>
     *     <sheet index="sheetName">
     *       <language index="lDEF">
     *         <field index="settings.fieldName">
     *           <value index="vDEF">fieldValue</value>
     *         </field>
     *       </language>
     *     </sheet>
     *   </data>
     * </T3FlexForms>
     *
     * @param string $xml FlexForms XML string
     * @return array Parsed data as [sheetName => [fieldName => value]]
     */
    private static function parseFlexFormsXml(string $xml): array
    {
        $result = [];

        if (empty($xml)) {
            return $result;
        }

        // Suppress XML errors and handle them gracefully
        libxml_use_internal_errors(true);
        $doc = simplexml_load_string($xml);
        libxml_clear_errors();

        if ($doc === false) {
            return $result;
        }

        // Navigate to data element
        if (!isset($doc->data)) {
            return $result;
        }

        // Iterate over sheets
        foreach ($doc->data->sheet as $sheet) {
            $sheetIndex = (string)($sheet['index'] ?? 'sDEF');

            // Get language element (usually lDEF)
            $language = $sheet->language ?? null;
            if ($language === null) {
                continue;
            }

            $result[$sheetIndex] = [];

            // Iterate over fields
            foreach ($language->field as $field) {
                $fieldIndex = (string)($field['index'] ?? '');
                if (empty($fieldIndex)) {
                    continue;
                }

                // Get value (usually vDEF)
                $value = '';
                if (isset($field->value)) {
                    $value = (string)$field->value;
                }

                // Remove 'settings.' prefix for consistency with FlexFormService output
                $fieldName = preg_replace('/^settings\./', '', $fieldIndex);
                $result[$sheetIndex][$fieldName] = $value;
            }
        }

        return $result;
    }

    /**
     * Parse ext_conf_template.txt and build values array in inputCollectionJson format
     * Format: {"category": {"varName": {"value": "currentValue"}, ...}, ...}
     *
     * @param string $templateContent
     * @param array $currentValues
     * @return array
     */
    private static function parseExtConfTemplate(string $templateContent, array $currentValues): array
    {
        $result = [];
        $lines = explode("\n", $templateContent);
        $currentCategory = 'general';

        for ($i = 0; $i < count($lines); $i++) {
            $line = trim($lines[$i]);

            // Check for category comment: # cat=category/subcategory; type=...; label=...
            if (preg_match('/^#\s*cat\s*=\s*([^\/;]+)/', $line, $matches)) {
                $currentCategory = trim($matches[1]);
            }

            // Check for variable assignment: varName = defaultValue
            if (preg_match('/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/', $line, $matches)) {
                $varName = trim($matches[1]);

                // Use current value if set, otherwise empty string
                $value = $currentValues[$varName] ?? '';

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

                $result[$currentCategory][$varName] = ['value' => $value];
            }
        }

        return $result;
    }

    /**
     * Set extension settings from provided config
     * Only updates extensions that are included in the config
     * Directly modifies LocalConfiguration.php without requiring full TYPO3 bootstrap
     *
     * @param array|string $config JSON string or array with extKey => values mapping
     *                             Values format: {"category": {"varName": {"value": "val"}, ...}, ...}
     * @return array Results with processed extensions and changes
     */
    public static function setExtensionSettings(array|string $config): 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());
            }
        }

        // Read LocalConfiguration.php directly
        $localConfigPath = Path::absoluteTypo3Conf(CONFIG_T3);
        if (!file_exists($localConfigPath)) {
            throw new \RuntimeException('LocalConfiguration.php not found: ' . $localConfigPath);
        }

        $localConfig = include $localConfigPath;
        if (!is_array($localConfig)) {
            throw new \RuntimeException('Invalid LocalConfiguration.php format');
        }

        // Ensure EXTENSIONS array exists
        if (!isset($localConfig['EXTENSIONS'])) {
            $localConfig['EXTENSIONS'] = [];
        }

        $results = [];
        $configChanged = false;

        foreach ($config as $extKey => $extData) {
            $result = [
                'extKey' => $extKey,
                'changes' => [],
            ];

            // Handle format with 'template' and 'values' keys (same as response format)
            if (is_array($extData) && isset($extData['values'])) {
                $values = $extData['values'];
                // If 'values' is a JSON string, decode it
                if (is_string($values)) {
                    $values = json_decode($values, true) ?? [];
                }
            } else {
                $values = $extData;
            }

            // Flatten the hierarchical format to TYPO3's flat format
            $flatValues = self::flattenExtSettingValues($values);

            // Get current values for this extension from LocalConfiguration
            $currentValues = $localConfig['EXTENSIONS'][$extKey] ?? [];

            // Track changes and merge values
            foreach ($flatValues as $varName => $newValue) {
                $oldValue = $currentValues[$varName] ?? null;

                if ($oldValue !== $newValue) {
                    $result[API_RESULT_KEY_CHANGES][] = [
                        API_CHANGE_KEY_FIELD => $varName,
                        API_CHANGE_KEY_OLD => $oldValue,
                        API_CHANGE_KEY_NEW => $newValue,
                    ];
                    $currentValues[$varName] = $newValue;
                    $configChanged = true;
                }
            }

            // Update the extension config in the array
            if (!empty($result[API_RESULT_KEY_CHANGES])) {
                $localConfig['EXTENSIONS'][$extKey] = $currentValues;
            }

            $results[] = $result;
        }

        // Write back to LocalConfiguration.php if changed
        if ($configChanged) {
            $result = self::writeLocalConfiguration($localConfigPath, $localConfig);
            if ($result !== true) {
                return [
                    OIDC_KEY_PROCESSED => count($results),
                    'configUpdated' => false,
                    OIDC_KEY_ERROR => $result,
                    OIDC_KEY_RESULTS => $results,
                ];
            }

            // Clear cache
            self::clearSystemCache();
        }

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

    /**
     * Write LocalConfiguration.php with proper PHP array formatting
     *
     * @param string $path Path to LocalConfiguration.php
     * @param array $config Configuration array to write
     * @return bool|string True on success, error message on failure
     */
    private static function writeLocalConfiguration(string $path, array $config): bool|string
    {
        // Generate PHP code for the configuration array
        $phpCode = "<?php\nreturn " . self::arrayExport($config, 0) . ";\n";

        // Write to file
        $result = file_put_contents($path, $phpCode, LOCK_EX);
        if ($result === false) {
            return 'Failed to write LocalConfiguration.php';
        }

        // Clear opcache if available
        if (function_exists('opcache_invalidate')) {
            opcache_invalidate($path, true);
        }

        return true;
    }

    /**
     * Export array as PHP code with proper formatting (like var_export but cleaner)
     *
     * @param mixed $var Variable to export
     * @param int $indent Current indentation level
     * @return string PHP code representation
     */
    private static function arrayExport(mixed $var, int $indent): string
    {
        if (!is_array($var)) {
            return var_export($var, true);
        }

        $spaces = str_repeat('    ', $indent);
        $innerSpaces = str_repeat('    ', $indent + 1);

        // Check if array is sequential (list) or associative
        $isSequential = array_keys($var) === range(0, count($var) - 1);

        $items = [];
        foreach ($var as $key => $value) {
            $exportedValue = self::arrayExport($value, $indent + 1);
            if ($isSequential) {
                $items[] = $innerSpaces . $exportedValue;
            } else {
                $exportedKey = var_export($key, true);
                $items[] = $innerSpaces . $exportedKey . ' => ' . $exportedValue;
            }
        }

        if (empty($items)) {
            return '[]';
        }

        return "[\n" . implode(",\n", $items) . ",\n" . $spaces . ']';
    }

    /**
     * Flatten hierarchical ext setting values to TYPO3's flat format
     * Input: {"category": {"varName": {"value": "val"}, ...}, ...}
     * Output: {"varName": "val", ...}
     *
     * @param array $values
     * @return array
     */
    private static function flattenExtSettingValues(array $values): array
    {
        $flat = [];

        foreach ($values as $categoryOrVar => $content) {
            if (is_array($content)) {
                // Check if this is a category (contains arrays with 'value' key)
                // or a direct value entry
                foreach ($content as $varName => $varContent) {
                    if (is_array($varContent) && array_key_exists('value', $varContent)) {
                        // Format: {"category": {"varName": {"value": "val"}}}
                        $flat[$varName] = $varContent['value'];
                    } elseif (is_array($varContent)) {
                        // Nested category, recurse
                        foreach ($varContent as $nestedVar => $nestedContent) {
                            if (is_array($nestedContent) && array_key_exists('value', $nestedContent)) {
                                $flat[$nestedVar] = $nestedContent['value'];
                            }
                        }
                    } else {
                        // Direct flat format: {"varName": "val"}
                        $flat[$varName] = $varContent;
                    }
                }
            } else {
                // Direct flat format at top level
                $flat[$categoryOrVar] = $content;
            }
        }

        return $flat;
    }

    /**
     * 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 = self::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
     */
    private 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
     */
    private 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
     */
    private 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 = self::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
     */
    private 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 = self::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
     */
    private 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
     */
    private 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,
        ];
    }

}