<?php

/*
 * RCMCardDAV - CardDAV plugin for Roundcube webmail
 *
 * Copyright (C) 2011-2022 Benjamin Schieder <rcmcarddav@wegwerf.anderdonau.de>,
 *                         Michael Stilkerich <ms@mike2k.de>
 *
 * This file is part of RCMCardDAV.
 *
 * RCMCardDAV is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 2 of the License, or
 * (at your option) any later version.
 *
 * RCMCardDAV is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with RCMCardDAV. If not, see <https://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace MStilkerich\RCMCardDAV\Frontend;

use Exception;
use Psr\Log\LoggerInterface;
use MStilkerich\RCMCardDAV\{Config, RoundcubeLogger};
use MStilkerich\CardDavClient\AddressbookCollection;

/**
 * Represents the administrative settings of the plugin.
 *
 * @psalm-import-type Int1 from AddressbookManager
 * @psalm-import-type AbookCfg from AddressbookManager
 * @psalm-import-type AccountCfg from AddressbookManager
 * @psalm-import-type AccountSettings from AddressbookManager
 * @psalm-import-type AbookSettings from AddressbookManager
 *
 * @psalm-type PasswordStoreScheme = 'plain' | 'base64' | 'des_key' | 'encrypted'
 * @psalm-type ConfigurablePresetAttr = 'accountname'|'discovery_url'|'username'|'password'|'rediscover_time'|
 *                                      'active'|'refresh_time'|'use_categories'|'readonly'|'require_always_email'|
 *                                      'name'|'preemptive_basic_auth'|'ssl_noverify'
 * @psalm-type SpecialAbookType = 'collected_recipients'|'collected_senders'|'default_addressbook'
 * @psalm-type SpecialAbookMatch = array{preset: string, matchname?: non-empty-string, matchurl?: non-empty-string}
 *
 * @psalm-type PresetExtraAbook = array{
 *     url: string,
 *     name: string,
 *     active: Int1,
 *     readonly: Int1,
 *     refresh_time: numeric-string,
 *     use_categories: Int1,
 *     fixed: list<ConfigurablePresetAttr>,
 *     require_always_email: Int1,
 * }
 *
 * @psalm-type Preset = array{
 *     accountname: string,
 *     username: string,
 *     password: string,
 *     discovery_url: ?string,
 *     rediscover_time: numeric-string,
 *     hide: Int1,
 *     preemptive_basic_auth: Int1,
 *     ssl_noverify: Int1,
 *     name: string,
 *     active: Int1,
 *     readonly: Int1,
 *     refresh_time: numeric-string,
 *     use_categories: Int1,
 *     fixed: list<ConfigurablePresetAttr>,
 *     require_always_email: Int1,
 *     extra_addressbooks?: array<string, PresetExtraAbook>,
 * }
 *
 * @psalm-type SettingSpecification=array{'url'|'timestr'|'string'|'bool'|'string[]'|'skip', bool}
 */
class AdminSettings
{
    /** @var list<PasswordStoreScheme> List of supported password store schemes */
    public const PWSTORE_SCHEMES = [ 'plain', 'base64', 'des_key', 'encrypted' ];

    /**
     * @var list<string> ALWAYS_FIXED
     *     List of attributes that are always fixed. This makes sure the attribute is updated from the preset on login.
     */
    private const ALWAYS_FIXED = ['readonly'];

    /**
     * @var list<string> ABOOK_ATTRS
     *     List of attributes that are applicable to an addressbook. Used to filter out those attributes from the preset
     *     that are relevant for the template addressbook settings.
     */
    private const ABOOK_ATTRS = ['name', 'active', 'refresh_time', 'use_categories', 'require_always_email'];

    /** @var Preset Default values for the preset attributes */
    private const PRESET_DEFAULTS = [
        'accountname'        => '',
        'username'           => '',
        'password'           => '',
        'discovery_url'      => null,
        'rediscover_time'    => '86400',

        'hide'               => '0',
        'preemptive_basic_auth' => '0',
        'ssl_noverify'       => '0',

        'name'               => '%N',
        'active'             => '1',
        'readonly'           => '0',
        'refresh_time'       => '3600',
        'use_categories'     => '1',
        'fixed'              => [],
        'require_always_email' => '0',
    ];

    /**
     * @var array<string, SettingSpecification> PRESET_SETTINGS_COMMON
     *   This describes the valid attributes in a preset configuration that are available in the main account but can
     *   also be overridden for extra addressbooks in their preset configuration.
     */
    private const PRESET_SETTINGS_COMMON = [
        //                    type,       mandatory
        'name'           => [ 'string',   false ],
        'active'         => [ 'bool',     false ],
        'readonly'       => [ 'bool',     false ],
        'refresh_time'   => [ 'timestr',  false ],
        'use_categories' => [ 'bool',     false ],
        'fixed'          => [ 'string[]', false ],
        'require_always_email' => [ 'bool', false ],
    ];

    /**
     * @var array<string, SettingSpecification> PRESET_SETTINGS_EXTRA_ABOOK
     *   This describes the valid attributes in a preset configuration of an extra addressbook (non-discovered), their
     *   data type, whether they are mandatory to be specified by the admin, and the default value for optional
     *   attributes.
     */
    private const PRESET_SETTINGS_EXTRA_ABOOK = [
        //                    type,       mandatory
        'url'            => [ 'url',      true  ],
    ] + self::PRESET_SETTINGS_COMMON;

    /**
     * @var array<string, SettingSpecification> PRESET_SETTINGS
     *   This describes the valid attributes in a preset configuration, their data type, whether they are mandatory to
     *   be specified by the admin, and the default value for optional attributes.
     */
    private const PRESET_SETTINGS = [
        //                        type,       mandatory
        'accountname'        => [ 'string',   false ],
        'username'           => [ 'string',   false ],
        'password'           => [ 'string',   false ],
        'discovery_url'      => [ 'url',      false ],
        'rediscover_time'    => [ 'timestr',  false ],
        'hide'               => [ 'bool',     false ],
        'preemptive_basic_auth' => [ 'bool',  false ],
        'ssl_noverify'       => [ 'bool',     false ],
        'extra_addressbooks' => [ 'skip',     false ],
    ] + self::PRESET_SETTINGS_COMMON;

    /**
     * @var PasswordStoreScheme encryption scheme
     * @readonly
     */
    public $pwStoreScheme = 'encrypted';

    /**
     * @var bool Global preference "fixed"
     * @readonly
     */
    public $forbidCustomAddressbooks = false;

    /**
     * @var bool Global preference "hide_preferences"
     * @readonly
     */
    public $hidePreferences = false;

    /**
     * @var array<SpecialAbookType,SpecialAbookMatch> Match settings for special addressbooks
     */
    private $specialAbookMatchers = [];

    /**
     * @var array<string, Preset> Presets from config.inc.php
     */
    private $presets = [];

    /**
     * Initializes AdminSettings from a config.inc.php file, using default values if that file is not available.
     *
     * @param string $configfile Path of the config.inc.php file to load.
     */
    public function __construct(string $configfile, LoggerInterface $logger, LoggerInterface $httpLogger)
    {
        $prefs = [];
        if (file_exists($configfile)) {
            include($configfile);
            /** @psalm-var mixed $prefs Will be set in the configfile. */

            if (!is_array($prefs)) {
                $logger->error("Error in config.inc.php: \$prefs must be an array");
                return;
            }
        }

        $gprefs = [];
        if (isset($prefs['_GLOBAL'])) {
            if (is_array($prefs['_GLOBAL'])) {
                $gprefs = $prefs['_GLOBAL'];
            }
            unset($prefs['_GLOBAL']);
        }

        // Extract global preferences
        if (isset($gprefs['pwstore_scheme'])) {
            $scheme = (string) $gprefs['pwstore_scheme'];

            if (in_array($scheme, self::PWSTORE_SCHEMES)) {
                /** @var PasswordStoreScheme $scheme */
                $this->pwStoreScheme = $scheme;
            } else {
                $logger->error("Invalid pwStoreScheme $scheme in config.inc.php - using default 'encrypted'");
            }
        }

        $this->forbidCustomAddressbooks = !empty($gprefs['fixed'] ?? false);
        $this->hidePreferences = !empty($gprefs['hide_preferences'] ?? false);

        foreach (['loglevel' => $logger, 'loglevel_http' => $httpLogger] as $setting => $cfgdLogger) {
            if (($cfgdLogger instanceof RoundcubeLogger) && isset($gprefs[$setting])) {
                try {
                    $cfgdLogger->setLogLevel((string) $gprefs[$setting]);
                } catch (Exception $e) {
                    $logger->error("Cannot set configured loglevel: " . $e->getMessage());
                }
            }
        }

        // Store presets
        foreach ($prefs as $presetName => $preset) {
            if (!is_string($presetName) || strlen($presetName) == 0) {
                $logger->error("A preset key must be a non-empty string - ignoring preset!");
                continue;
            }

            if (!is_array($preset)) {
                $logger->error("A preset definition must be an array of settings - ignoring preset $presetName!");
                continue;
            }

            $this->addPreset($presetName, $preset, $logger);
        }

        // Extract filter for special addressbooks
        foreach ([ 'collected_recipients', 'collected_senders', 'default_addressbook' ] as $setting) {
            if (isset($gprefs[$setting]) && is_array($gprefs[$setting])) {
                $matchSettings = $gprefs[$setting];

                if (
                    isset($matchSettings['preset'])
                    && is_string($matchSettings['preset'])
                    && key_exists($matchSettings['preset'], $this->presets)
                ) {
                    $presetName = $matchSettings['preset'];
                    $matchSettings2 = [ 'preset' => $presetName ];
                    foreach (['matchname', 'matchurl'] as $matchType) {
                        if (isset($matchSettings[$matchType]) && is_string($matchSettings[$matchType])) {
                            $matchexpr = $matchSettings[$matchType];
                            $matchexpr = Utils::replacePlaceholdersUrl($matchexpr, true);
                            if (strlen($matchexpr) > 0) {
                                /** @psalm-var non-empty-string $matchexpr */
                                $matchSettings2[$matchType] = $matchexpr;
                            }
                        }
                    }

                    $this->specialAbookMatchers[$setting] = $matchSettings2;
                } else {
                    $logger->error("Setting for $setting must include a valid preset attribute");
                }
            }
        }
    }

    /**
     * Returns the preset with the given name.
     *
     * Optionally, the URL of a manually added addressbook may be given. In this case, the returned preset will contain
     * the values of that specific addressbook instead of those for auto-discovered addressbooks.
     *
     * @return Preset
     */
    public function getPreset(string $presetName, ?string $xabookUrl = null): array
    {
        if (!isset($this->presets[$presetName])) {
            throw new Exception("Query for undefined preset $presetName");
        }

        $preset = $this->presets[$presetName];

        if (isset($xabookUrl) && isset($preset['extra_addressbooks'][$xabookUrl])) {
            /**
             * @psalm-var Preset $preset psalm assumes that extra keys (e.g. hide) may be present in the xabook preset
             *            with unknown type, but this is not the case
             */
            $preset = $preset['extra_addressbooks'][$xabookUrl] + $preset;
        }

        unset($preset['extra_addressbooks']);
        return $preset;
    }

    /**
     * Creates / updates / deletes preset addressbooks.
     */
    public function initPresets(AddressbookManager $abMgr, Config $infra): void
    {
        $logger = $infra->logger();

        try {
            $userId = (string) $_SESSION['user_id'];

            // Get all existing accounts of this user that have been created from presets
            $accountIds = $abMgr->getAccountIds(true);
            $existingPresets = [];
            foreach ($accountIds as $accountId) {
                $account = $abMgr->getAccountConfig($accountId);
                /** @psalm-var string $presetName Not null because filtered by getAccountIds() */
                $presetName = $account['presetname'];
                $existingPresets[$presetName] = $accountId;
            }

            // Walk over the current presets configured by the admin and add, update or delete addressbooks
            foreach ($this->presets as $presetName => $preset) {
                try {
                    $logger->info("Adding/Updating preset $presetName for user $userId");

                    // Map URL => ID of the existing extra addressbooks in the DB for this preset
                    $existingExtraAbooksByUrl = [];

                    if (isset($existingPresets[$presetName])) {
                        $accountId = $existingPresets[$presetName];

                        // Update the extra addressbooks with the current set of the admin
                        $existingExtraAbooksByUrl = array_column(
                            $abMgr->getAddressbookConfigsForAccount($accountId, AddressbookManager::ABF_EXTRA),
                            'id',
                            'url'
                        );

                        // Delete extra addressbooks that do not exist anymore in preset
                        $delXAbooks = array_diff_key($existingExtraAbooksByUrl, $preset['extra_addressbooks'] ?? []);
                        if (!empty($delXAbooks)) {
                            $logger->info("Deleting deprecated extra addressbooks in $presetName for user $userId");
                            $abMgr->deleteAddressbooks(array_values($delXAbooks));
                            $existingExtraAbooksByUrl = array_diff_key($existingExtraAbooksByUrl, $delXAbooks);
                        }

                        // Update the fixed account/addressbook settings with the current admin values
                        $this->updatePresetSettings($presetName, $accountId, $abMgr, $infra);
                    } else {
                        // Add new account first (addressbooks follow below)
                        $accountCfg = $preset;

                        // unset non-string attributes to psalm type compatibility
                        unset($accountCfg['extra_addressbooks'], $accountCfg['fixed']);

                        $accountCfg['presetname'] = $presetName;
                        $accountId = $abMgr->insertAccount($accountCfg);
                    }

                    $accountCfg = $abMgr->getAccountConfig($accountId);

                    // Create the extra addressbooks for this preset
                    foreach (array_keys($preset['extra_addressbooks'] ?? []) as $xabookUrl) {
                        if (!isset($existingExtraAbooksByUrl[$xabookUrl])) {
                            // create new
                            $this->insertExtraAddressbook($abMgr, $infra, $accountCfg, $xabookUrl, $presetName);
                        }
                    }
                } catch (Exception $e) {
                    $logger->error("Error adding/updating preset $presetName for user $userId {$e->getMessage()}");
                }

                unset($existingPresets[$presetName]);
            }

            // delete existing preset addressbooks that were removed by admin
            foreach ($existingPresets as $presetName => $accountId) {
                $logger->info("Deleting preset $presetName for user $userId");
                $abMgr->deleteAccount($accountId); // also deletes the addressbooks
            }
        } catch (Exception $e) {
            $logger->error("Error initializing preconfigured addressbooks: {$e->getMessage()}");
        }
    }

    /**
     * Updates the fixed fields of account and addressbooks derived from a preset with the current admin settings.
     *
     * This is done for all addressbooks of the account, including the template addressbook.
     *
     * Only fixed fields are updated, as non-fixed fields may have been changed by the user.
     *
     * @param AddressbookManager $abMgr The addressbook manager.
     */
    private function updatePresetSettings(
        string $presetName,
        string $accountId,
        AddressbookManager $abMgr,
        Config $infra
    ): void {
        $accountCfg = $abMgr->getAccountConfig($accountId);
        $this->updatePresetAccount($accountCfg, $presetName, $abMgr);

        $abookCfgs = $abMgr->getAddressbookConfigsForAccount($accountId, AddressbookManager::ABF_ALL);
        foreach ($abookCfgs as $abookCfg) {
            $this->updatePresetAddressbook($accountCfg, $abookCfg, $presetName, $abMgr, $infra);
        }
    }

    /**
     * Updates the fixed fields of a preset account with the current admin settings.
     *
     * Only fixed fields are updated, as non-fixed fields may have been changed by the user.
     *
     * @param AccountCfg $accountCfg
     */
    private function updatePresetAccount(array $accountCfg, string $presetName, AddressbookManager $abMgr): void
    {
        $preset = $this->getPreset($presetName);

        // update only those attributes marked as fixed by the admin
        // otherwise there may be user changes that should not be destroyed
        $pa = [];
        foreach ($preset['fixed'] as $k) {
            if (isset($preset[$k]) && isset($accountCfg[$k]) && $accountCfg[$k] != $preset[$k]) {
                $pa[$k] = $preset[$k];
            }
        }

        // only update if something changed
        if (!empty($pa)) {
            /** @psalm-var AccountSettings $pa */
            $abMgr->updateAccount($accountCfg['id'], $pa);
        }
    }

    /**
     * Updates the fixed fields of one preset addressbook with the current admin settings.
     *
     * Only fixed fields are updated, as non-fixed fields may have been changed by the user.
     *
     * @param AccountCfg $accountCfg
     * @param AbookCfg $abookCfg
     */
    private function updatePresetAddressbook(
        array $accountCfg,
        array $abookCfg,
        string $presetName,
        AddressbookManager $abMgr,
        Config $infra
    ): void {
        // extra addressbooks (discovered == 0) can have individual preset settings
        $preset = $this->getPreset($presetName, $abookCfg['url']);

        // update only those attributes marked as fixed by the admin
        // otherwise there may be user changes that should not be destroyed
        $pa = [];
        foreach ($preset['fixed'] as $k) {
            if (isset($preset[$k]) && isset($abookCfg[$k])) {
                try {
                    // no expansion of name template string for the template addressbook
                    if (empty($abookCfg['template']) && $k === 'name') {
                        $account = Config::makeAccount($accountCfg);
                        $abook = $infra->makeWebDavResource($abookCfg['url'], $account);
                        if ($abook instanceof AddressbookCollection) {
                            $preset['name'] = $abMgr->replacePlaceholdersAbookName(
                                $preset['name'],
                                $accountCfg,
                                $abook
                            );
                        } else {
                            throw new Exception("no addressbook collection at given URL");
                        }
                    }

                    if ($abookCfg[$k] != $preset[$k]) {
                        $pa[$k] = $preset[$k];
                    }
                } catch (Exception $e) {
                    // skip updating the name but update the remaining attributes, plus log an error
                    $logger = $infra->logger();
                    $logger->error("Cannot update name of addressbook {$abookCfg['id']}: {$e->getMessage()}");
                }
            }
        }

        // only update if something changed
        if (!empty($pa)) {
            /** @psalm-var AbookSettings $pa */
            $abMgr->updateAddressbook($abookCfg['id'], $pa);
        }
    }

    /**
     * Adds a new non-discovered addressbook to an account.
     *
     * Performs a check with the server ensuring that the addressbook actually exists and can be accessed.
     *
     * @param AccountCfg $accountCfg Array with the settings of the account
     */
    private function insertExtraAddressbook(
        AddressbookManager $abMgr,
        Config $infra,
        array $accountCfg,
        string $xabookUrl,
        string $presetName
    ): void {
        try {
            $account = Config::makeAccount($accountCfg);
            $abook = $infra->makeWebDavResource($xabookUrl, $account);
            if ($abook instanceof AddressbookCollection) {
                // Get values for the optional settings that the admin may have configured as part of the preset
                $abookTmpl = $this->getPreset($presetName, $xabookUrl);

                // unset non-string attributes to psalm type compatibility
                unset($abookTmpl['extra_addressbooks'], $abookTmpl['fixed']);

                $abookTmpl['account_id'] = $accountCfg['id'];
                $abookTmpl['discovered'] = '0';
                $abookTmpl['sync_token'] = '';
                $abookTmpl['url'] = $xabookUrl;
                $abookTmpl['name'] = $abMgr->replacePlaceholdersAbookName($abookTmpl['name'], $accountCfg, $abook);
                $abMgr->insertAddressbook($abookTmpl);
            } else {
                throw new Exception("no addressbook collection at given URL");
            }
        } catch (Exception $e) {
            $logger = $infra->logger();
            $logger->error("Failed to add extra addressbook $xabookUrl for preset $presetName: " . $e->getMessage());
        }
    }

    /**
     * Adds the given preset from config.inc.php to $this->presets.
     */
    private function addPreset(string $presetName, array $preset, LoggerInterface $logger): void
    {
        try {
            /** @psalm-var Preset $result Checked by parsePresetArray() */
            $result = $this->parsePresetArray(
                self::PRESET_SETTINGS,
                $preset,
                ['accountname' => $presetName] + self::PRESET_DEFAULTS
            );

            // Parse extra addressbooks
            $result['extra_addressbooks'] = [];
            if (isset($preset['extra_addressbooks'])) {
                if (!is_array($preset['extra_addressbooks'])) {
                    throw new Exception("setting extra_addressbooks must be an array");
                }

                foreach (array_keys($preset['extra_addressbooks']) as $k) {
                    if (is_array($preset['extra_addressbooks'][$k])) {
                        /** @psalm-var PresetExtraAbook $xabook Checked by parsePresetArray() */
                        $xabook = $this->parsePresetArray(
                            self::PRESET_SETTINGS_EXTRA_ABOOK,
                            $preset['extra_addressbooks'][$k],
                            $result
                        );

                        $result['extra_addressbooks'][$xabook['url']] = $xabook;
                    } else {
                        throw new Exception("setting extra_addressbooks[$k] must be an array");
                    }
                }
            }

            $this->presets[$presetName] = $result;
        } catch (Exception $e) {
            $logger->error("Error in preset $presetName: " . $e->getMessage());
        }
    }

    /**
     * Parses / checks a user-input array according to a settings specification.
     *
     * @param array<string, SettingSpecification> $spec The specification of the expected fields.
     * @param array $preset The user-input array
     * @param Preset $defaults An array with defaults for all settings (for mandatory settings, they will not be used)
     * @return array If no error, the resulting array, containing only attributes from $spec.
     */
    private function parsePresetArray(array $spec, array $preset, array $defaults): array
    {
        $result = [];
        foreach ($spec as $attr => $specs) {
            [ $type, $mandatory ] = $specs;

            if ($type === 'skip') {
                // this item has a special handler
                continue;
            }

            if (isset($preset[$attr])) {
                switch ($type) {
                    case 'string':
                    case 'timestr':
                    case 'url':
                        if (is_string($preset[$attr])) {
                            if ($type == 'timestr') {
                                $result[$attr] = (string) Utils::parseTimeParameter($preset[$attr]);
                            } elseif ($type == 'url') {
                                $result[$attr] = Utils::replacePlaceholdersUrl($preset[$attr]);
                            } else {
                                $result[$attr] = $preset[$attr];
                            }
                        } else {
                            throw new Exception("setting $attr must be a string");
                        }
                        break;

                    case 'bool':
                        $result[$attr] = empty($preset[$attr]) ? '0' : '1';
                        break;

                    case 'string[]':
                        if (is_array($preset[$attr])) {
                            $result[$attr] = [];
                            foreach (array_keys($preset[$attr]) as $k) {
                                if (is_string($preset[$attr][$k])) {
                                    $result[$attr][] = $preset[$attr][$k];
                                } else {
                                    throw new Exception("setting $attr\[$k\] must be string");
                                }
                            }
                        } else {
                            throw new Exception("setting $attr must be array");
                        }
                }
            } elseif ($mandatory) {
                throw new Exception("required setting $attr is not set");
            } else {
                $result[$attr] = $defaults[$attr];
            }
        }

        /** @psalm-var Preset|PresetExtraAbook $result */

        // Add attributes that are never user-configurable to fixed to they are updated from admin preset on login
        foreach (self::ALWAYS_FIXED as $attr) {
            if (!in_array($attr, $result['fixed'])) {
                $result['fixed'][] = $attr;
            }
        }

        return $result;
    }

    /**
     * Gets the special addressbooks that are configured to CardDAV sources by the admin.
     *
     * These special addressbooks as of roundcube 1.5 are collected recipients and collected senders. The admin can
     * configure a match expression for the name or the URL of the addressbook, that is looked for in a specific preset.
     *
     * @return array<SpecialAbookType, string> ID for each special addressbook for that a CardDAV source is selected
     */
    public function getSpecialAddressbooks(AddressbookManager $abMgr, Config $infra): array
    {
        $logger = $infra->logger();
        $ret = [];

        if (empty($this->specialAbookMatchers)) {
            return $ret;
        }

        // Create a mapping Presetname => AccountConfig
        $presetIdsByPresetname = [];
        foreach ($abMgr->getAccountIds(true) as $accountId) {
            $accountCfg = $abMgr->getAccountConfig($accountId);
            /** @psalm-var string $presetName Not null because filtered by getAccountIds() */
            $presetName = $accountCfg['presetname'];
            $presetIdsByPresetname[$presetName] = $accountId;
        }

        // Search for the addressbook to use for each of the special addressbooks if configured
        foreach ($this->specialAbookMatchers as $type => $matchSettings) {
            $presetName = $matchSettings['preset'];
            $matches = [];

            // When an admin creates a new preset in the configuration and a user is still logged on, the user will not
            // have an account for that preset yet until the next login. If this new preset is referred to for a special
            // addressbook, we cannot set it just yet.
            if (!isset($presetIdsByPresetname[$presetName])) {
                $logger->debug("Cannot set special addressbook $type, no account for preset $presetName in DB");
                continue;
            }

            $accountId = $presetIdsByPresetname[$presetName];

            // Read-only, inactive and template addressbooks are not considered for candidates
            $abCandidates = $abMgr->getAddressbookConfigsForAccount($accountId, AddressbookManager::ABF_ACTIVE_RW);
            foreach ($abCandidates as $abookCfg) {
                // check all addressbooks for that preset
                // All specified matchers must match
                // If no matcher is set, any addressbook of the preset is considered a match
                foreach (['matchname', 'matchurl'] as $matchType) {
                    $matchexpr = $matchSettings[$matchType] ?? 0;
                    if (is_string($matchexpr)) {
                        /** @psalm-var non-empty-string $matchexpr */
                        if (!preg_match($matchexpr, $abookCfg[substr($matchType, 5)])) {
                            continue 2;
                        }
                    }
                }

                $matches[] = $abookCfg['id'];
            }

            // we need exactly one match, in any other case we leave the roundcube setting as is
            $numMatches = count($matches);
            if ($numMatches != 1) {
                $logger->error("Cannot set special addressbook $type, there are $numMatches candidates (need: 1)");
            } else {
                $ret[$type] = $matches[0];
            }
        }

        return $ret;
    }

    /**
     * Provides the addressbook template for new addressbooks of an account, incorporating fixed settings of presets.
     *
     * This function can be used for both user-defined and preset accounts.
     *
     * For a user-defined account, it will simply provide the addressbook template of that account, if available, or
     * otherwise an empty array.
     *
     * For a preset account, it will provide the preset settings unless a template addressbook is also available for the
     * account. In the latter case, it will adapt the template addressbook to overwrite all fixed fields with the
     * settings from the preset.
     *
     * @param AddressbookManager $abMgr
     * @param string $accountId ID of the account for that the addressbook template shall be provided
     * @return AbookSettings The initial addressbook settings
     */
    public function getAddressbookTemplate(AddressbookManager $abMgr, string $accountId): array
    {
        $accountCfg = $abMgr->getAccountConfig($accountId);
        $abookTmpl = $abMgr->getTemplateAddressbookForAccount($accountId);

        $presetName = $accountCfg['presetname'];
        if ($presetName !== null) {
            $preset = $this->getPreset($presetName);
            if ($abookTmpl === null) {
                $abookTmpl = array_intersect_key($preset, array_flip(self::ABOOK_ATTRS));
            } else {
                // take the fixed attributes from the preset
                foreach (array_keys($abookTmpl) as $attr) {
                    if (in_array($attr, $preset['fixed'])) {
                        if (isset($preset[$attr])) {
                            $abookTmpl[$attr] = $preset[$attr];
                        }
                    }
                }
            }
        }

        /** @psalm-var ?AbookSettings $abookTmpl */
        return $abookTmpl ?? [];
    }
}

// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120