<?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 MStilkerich\RCMCardDAV\{Addressbook, Config};
use MStilkerich\CardDavClient\AddressbookCollection;
use MStilkerich\RCMCardDAV\Db\AbstractDatabase;

/**
 * @psalm-import-type FullAccountRow from AbstractDatabase
 * @psalm-import-type FullAbookRow from AbstractDatabase
 *
 * Describes for each database field of an addressbook / account: mandatory on insert, updatable
 * @psalm-type SettingSpecification = array{bool, bool}
 *
 * The data types AccountCfg / AbookCfg describe the configuration of an account / addressbook as stored in the
 * database, with mappings of bitfields to the individual attributes.
 *
 * @psalm-type Int1 = '0' | '1'
 *
 * @psalm-type AccountCfg = array{
 *     id: string,
 *     user_id: string,
 *     accountname: string,
 *     username: string,
 *     password: string,
 *     discovery_url: ?string,
 *     last_discovered: numeric-string,
 *     rediscover_time: numeric-string,
 *     presetname: ?string,
 *     flags: numeric-string,
 *     preemptive_basic_auth: Int1,
 *     ssl_noverify: Int1
 * }
*
 * @psalm-type AbookCfg = array{
 *     id: string,
 *     account_id: string,
 *     name: string,
 *     url: string,
 *     last_updated: numeric-string,
 *     refresh_time: numeric-string,
 *     sync_token: string,
 *     flags: numeric-string,
 *     active: Int1,
 *     use_categories: Int1,
 *     discovered: Int1,
 *     readonly: Int1,
 *     require_always_email: Int1,
 *     template: Int1
 * }
 *
 * XXX temporary workaround for vimeo/psalm#8984 - This should be defined in UI.php instead
 * @psalm-type EnhancedAbookCfg = AbookCfg & array{srvname: string, srvdesc: string}
 *
 * The data types AccountSettings / AbookSettings describe the attributes of an account / addressbook row in the
 * corresponding DB table, that can be used for inserting / updating the addressbook. Contrary to the  AccountCfg /
 * AbookCfg types:
 *   - all keys are optional (for use of update of individual columns, others are not specified)
 *   - DB managed columns (particularly: id) are missing
 *   - Additional entries are permitted, the consuming APIs of this class take care to only interpret the relevant
 *     entries. This is to allow AbookCfg / AccountCfg objects to be used as AccountSettings / AbookSettings objects.
 *
 * @psalm-type AccountSettings = array{
 *     accountname?: string,
 *     username?: string,
 *     password?: string,
 *     discovery_url?: ?string,
 *     rediscover_time?: numeric-string,
 *     last_discovered?: numeric-string,
 *     presetname?: ?string,
 *     preemptive_basic_auth?: Int1,
 *     ssl_noverify?: Int1
 * } & array<string, ?string>
 *
 * @psalm-type AbookSettings = array{
 *     account_id?: string,
 *     name?: string,
 *     url?: string,
 *     last_updated?: numeric-string,
 *     refresh_time?: numeric-string,
 *     sync_token?: string,
 *     active?: Int1,
 *     use_categories?: Int1,
 *     discovered?: Int1,
 *     readonly?: Int1,
 *     require_always_email?: Int1,
 *     template?: Int1
 * } & array<string, ?string>
 *
 * Type for an addressbook filter on the addressbook flags mask, expvalue
 *
 * @psalm-type AbookFilter = list{int, int}
 */
class AddressbookManager
{
    /**
     * @var AbookFilter Filter yields all addressbooks, including templates.
     */
    public const ABF_ALL = [ 0, 0 ];

    /**
     * @var AbookFilter Filter yields all addressbooks, templates excluded.
     */
    public const ABF_REGULAR = [ 0x20, 0x00 ];

    /**
     * @var AbookFilter Filter yields all active addressbooks, templates excluded.
     */
    public const ABF_ACTIVE = [ 0x21, 0x01 ];

    /**
     * @var AbookFilter Filter yields all active writeable addressbooks, templates excluded.
     */
    public const ABF_ACTIVE_RW = [ 0x29, 0x01 ];

    /**
     * @var AbookFilter Filter yields all discovered addressbooks, templates excluded.
     */
    public const ABF_DISCOVERED = [ 0x24, 0x04 ];

    /**
     * @var AbookFilter Filter yields all non-discovered/extra addressbooks, templates excluded.
     */
    public const ABF_EXTRA = [ 0x24, 0x00 ];

    /**
     * @var AbookFilter Filter yields the template addressbook.
     */
    public const ABF_TEMPLATE = [ 0x20, 0x20 ];

    /**
     * @var array<string,SettingSpecification>
     *      List of user-/admin-configurable settings for an account. Note: The array must contain all fields of the
     *      AccountSettings type. Only fields listed in the array can be set via the insertAccount() / updateAccount()
     *      methods.
     */
    private const ACCOUNT_SETTINGS = [
        // [mandatory, updatable]
        'accountname' => [ true, true ],
        'username' => [ true, true ],
        'password' => [ true, true ],
        'discovery_url' => [ false, true ], // discovery URI can be NULL, disables discovery
        'rediscover_time' => [ false, true ],
        'last_discovered' => [ false, true ],
        'presetname' => [ false, false ],
        'preemptive_basic_auth' => [false, true],
        'ssl_noverify' => [false, true],
    ];

    /**
     * @var array<string,SettingSpecification>
     *      AbookSettings List of user-/admin-configurable settings for an addressbook. Note: The array must contain all
     *      fields of the AbookSettings type. Only fields listed in the array can be set via the insertAddressbook()
     *      / updateAddressbook() methods.
     */
    private const ABOOK_SETTINGS = [
        // [mandatory, updatable]
        'account_id' => [ true, false ],
        'name' => [ true, true ],
        'url' => [ true, false ],
        'refresh_time' => [ false, true ],
        'last_updated' => [ false, true ],
        'sync_token' => [ true, true ],

        'active'         => [ false, true ],
        'use_categories' => [ false, true ],
        'discovered'     => [ false, false ],
        'readonly'       => [ false, true ],
        'require_always_email' => [false, true],
        'template'       => [false, false],
    ];

    /** @var ?array<string, AccountCfg> $accountsDb
     *    Cache of the user's account DB entries. Associative array mapping account IDs to DB rows.
     */
    private $accountsDb = null;

    /** @var ?array<string, AbookCfg> $abooksDb
     *    Cache of the user's addressbook DB entries. Associative array mapping addressbook IDs to DB rows.
     */
    private $abooksDb = null;

    public function __construct()
    {
        // CAUTION: expected to be empty as no initialized plugin environment available yet
    }

    /**
     * Returns the IDs of all the user's accounts, optionally filtered.
     *
     * @param bool $presetsOnly If true, only the accounts created from an admin preset are returned.
     * @return list<string> The IDs of the user's accounts.
     */
    public function getAccountIds(bool $presetsOnly = false): array
    {
        $db = Config::inst()->db();

        if (!isset($this->accountsDb)) {
            $this->accountsDb = [];
            /** @var FullAccountRow $accrow */
            foreach ($db->get(['user_id' => (string) $_SESSION['user_id']], [], 'accounts') as $accrow) {
                $accountCfg = $this->accountRow2Cfg($accrow);
                $this->accountsDb[$accrow["id"]] = $accountCfg;
            }
        }

        $result = $this->accountsDb;

        if ($presetsOnly) {
            $result = array_filter($result, function (array $v): bool {
                return (strlen($v["presetname"] ?? "") > 0);
            });
        }

        return array_column($result, 'id');
    }

    /**
     * Retrieves an account configuration (database row) by its database ID.
     *
     * @param string $accountId ID of the account
     * @return AccountCfg The addressbook config.
     * @throws Exception If no account with the given ID exists for this user.
     */
    public function getAccountConfig(string $accountId): array
    {
        // make sure the cache is loaded
        $this->getAccountIds();

        // check that this addressbook ID actually refers to one of the user's addressbooks
        if (isset($this->accountsDb[$accountId])) {
            $accountCfg = $this->accountsDb[$accountId];
            $accountCfg["password"] = Utils::decryptPassword($accountCfg["password"]);
            return $accountCfg;
        }

        throw new Exception("No carddav account with ID $accountId");
    }

    /**
     * Inserts a new account into the database.
     *
     * @param AccountSettings $pa Array with the settings for the new account
     * @return string Database ID of the newly created account
     */
    public function insertAccount(array $pa): string
    {
        $db = Config::inst()->db();

        // check parameters
        if (isset($pa['password'])) {
            $pa['password'] = Utils::encryptPassword($pa['password']);
        }

        [ 'default' => $flagsInit, 'fields' => $flagAttrs ] = AbstractDatabase::FLAGS_COLS['accounts'];
        [ $cols, $vals ] = $this->prepareDbRow($pa, self::ACCOUNT_SETTINGS, true, $flagAttrs, $flagsInit);

        $cols[] = 'user_id';
        $vals[] = (string) $_SESSION['user_id'];

        $accountId = $db->insert("accounts", $cols, [$vals]);
        $this->accountsDb = null;
        return $accountId;
    }

    /**
     * Updates some settings of an account in the database.
     *
     * If the given ID does not refer to an account of the logged-in user, nothing is changed.
     *
     * @param string $accountId ID of the account
     * @param AccountSettings $pa Array with the settings to update
     */
    public function updateAccount(string $accountId, array $pa): void
    {
        // encrypt the password before storing it
        if (isset($pa['password'])) {
            $pa['password'] = Utils::encryptPassword($pa['password']);
        }

        $accountCfg = $this->getAccountConfig($accountId);
        $flagAttrs = AbstractDatabase::FLAGS_COLS['accounts']['fields'];
        $flagsInit = intval($accountCfg['flags']);
        [ $cols, $vals ] = $this->prepareDbRow($pa, self::ACCOUNT_SETTINGS, false, $flagAttrs, $flagsInit);

        $userId = (string) $_SESSION['user_id'];
        if (!empty($cols) && !empty($userId)) {
            $db = Config::inst()->db();
            $db->update(['id' => $accountId, 'user_id' => $userId], $cols, $vals, "accounts");
            $this->accountsDb = null;
        }
    }

    /**
     * Deletes the given account from the database.
     * @param string $accountId ID of the account
     */
    public function deleteAccount(string $accountId): void
    {
        $infra = Config::inst();
        $db = $infra->db();

        try {
            $db->startTransaction(false);

            // getAccountConfig() throws an exception if the ID is invalid / no account of the current user
            $this->getAccountConfig($accountId);

            $abookIds = array_column($this->getAddressbookConfigsForAccount($accountId, self::ABF_ALL), 'id');

            // we explicitly delete all data belonging to the account, since
            // cascaded deletes are not supported by all database backends
            $this->deleteAddressbooks($abookIds, true);

            $db->delete($accountId, 'accounts');

            $db->endTransaction();
        } catch (Exception $e) {
            $db->rollbackTransaction();
            throw $e;
        } finally {
            $this->accountsDb = null;
            $this->abooksDb = null;
        }
    }

    /**
     * Converts an addressbook DB row to an addressbook config.
     *
     * This means that fields that are stored differently in the DB than presented at application level are converted
     * from DB format to application level. Currently, this conversion is only needed for bitfields.
     *
     * @param FullAbookRow $abookrow
     * @return AbookCfg
     */
    private function abookRow2Cfg(array $abookrow): array
    {
        // set the application-level fields from the DB-level fields
        foreach (AbstractDatabase::FLAGS_COLS['addressbooks']['fields'] as $cfgAttr => $bitPos) {
            $abookrow[$cfgAttr] = (($abookrow['flags'] & (1 << $bitPos)) ? '1' : '0');
        }

        /** @psalm-var AbookCfg $abookrow Psalm does not keep track of the type of individual array members above */
        return $abookrow;
    }

    /**
     * Converts an account DB row to an account config.
     *
     * This means that fields that are stored differently in the DB than presented at application level are converted
     * from DB format to application level. Currently, this conversion is only needed for bitfields.
     *
     * @param FullAccountRow $row
     * @return AccountCfg
     */
    private function accountRow2Cfg(array $row): array
    {
        // set the application-level fields from the DB-level fields
        foreach (AbstractDatabase::FLAGS_COLS['accounts']['fields'] as $cfgAttr => $bitPos) {
            $row[$cfgAttr] = (($row['flags'] & (1 << $bitPos)) ? '1' : '0');
        }

        /** @psalm-var AccountCfg $row Psalm does not keep track of the type of individual array members above */
        return $row;
    }

    /**
     * Returns the IDs of all the user's addressbooks, optionally filtered.
     *
     * @psalm-assert !null $this->abooksDb
     * @param AbookFilter $filter
     * @param bool $presetsOnly If true, only the addressbooks created from an admin preset are returned.
     * @return list<string>
     */
    public function getAddressbookIds(array $filter = self::ABF_ACTIVE, bool $presetsOnly = false): array
    {
        $db = Config::inst()->db();

        if (!isset($this->abooksDb)) {
            $allAccountIds = $this->getAccountIds();
            $this->abooksDb = [];

            if (!empty($allAccountIds)) {
                /** @var FullAbookRow $abookrow */
                foreach ($db->get(['account_id' => $allAccountIds], [], 'addressbooks') as $abookrow) {
                    $abookCfg = $this->abookRow2Cfg($abookrow);
                    $this->abooksDb[$abookrow["id"]] = $abookCfg;
                }
            }
        }

        $result = $this->abooksDb;

        // filter out the addressbooks of the accounts matching the filter conditions
        if ($presetsOnly) {
            $accountIds = $this->getAccountIds($presetsOnly);
            $result = array_filter($result, function (array $v) use ($accountIds): bool {
                return in_array($v["account_id"], $accountIds);
            });
        }

        // filter out template addressbooks
        $result = array_filter($result, function (array $v) use ($filter): bool {
            return (($v["flags"] & $filter[0]) === $filter[1]);
        });

        return array_column($result, 'id');
    }

    /**
     * Retrieves an addressbook configuration (database row) by its database ID.
     *
     * @param string $abookId ID of the addressbook
     * @return AbookCfg The addressbook config.
     * @throws Exception If no addressbook with the given ID exists for this user.
     */
    public function getAddressbookConfig(string $abookId): array
    {
        // make sure the cache is loaded
        $this->getAddressbookIds();

        // check that this addressbook ID actually refers to one of the user's addressbooks
        if (isset($this->abooksDb[$abookId])) {
            return $this->abooksDb[$abookId];
        }

        throw new Exception("No carddav addressbook with ID $abookId");
    }

    /**
     * Returns the addressbooks for the given account.
     *
     * @param string $accountId
     * @param AbookFilter $filter
     * @return array<string, AbookCfg> The addressbook configs, indexed by addressbook id.
     */
    public function getAddressbookConfigsForAccount(string $accountId, array $filter = self::ABF_REGULAR): array
    {
        // make sure the given account is an account of this user - otherwise, an exception is thrown
        $this->getAccountConfig($accountId);

        // make sure the cache is filled
        $this->getAddressbookIds();

        return array_filter(
            $this->abooksDb,
            function (array $v) use ($accountId, $filter): bool {
                return $v["account_id"] == $accountId &&
                    (($v["flags"] & $filter[0]) === $filter[1]) ;
            }
        );
    }

    /**
     * Retrieves an addressbook by its database ID.
     *
     * @param string $abookId ID of the addressbook
     * @return Addressbook The addressbook object.
     * @throws Exception If no addressbook with the given ID exists for this user.
     */
    public function getAddressbook(string $abookId): Addressbook
    {
        $config = $this->getAddressbookConfig($abookId);
        $accountCfg = $this->getAccountConfig($config["account_id"]);

        $account = Config::makeAccount($accountCfg);

        return new Addressbook($abookId, $account, $config);
    }


    /**
     * Gets the template addressbook configuration for an account, if available.
     *
     * @return ?AbookCfg The template addressbook config for the account, null if none exists.
     */
    public function getTemplateAddressbookForAccount(string $accountId): ?array
    {
        $tmplAbooks = $this->getAddressbookConfigsForAccount($accountId, self::ABF_TEMPLATE);
        return empty($tmplAbooks) ? null : reset($tmplAbooks);
    }

    /**
     * Inserts a new addressbook into the database.
     * @param AbookSettings $pa Array with the settings for the new addressbook
     * @return string Database ID of the newly created addressbook
     */
    public function insertAddressbook(array $pa): string
    {
        $db = Config::inst()->db();

        [ 'default' => $flagsInit, 'fields' => $flagAttrs ] = AbstractDatabase::FLAGS_COLS['addressbooks'];
        [ $cols, $vals ] = $this->prepareDbRow($pa, self::ABOOK_SETTINGS, true, $flagAttrs, $flagsInit);

        // getAccountConfig() throws an exception if the ID is invalid / no account of the current user
        $this->getAccountConfig($pa['account_id'] ?? '');

        $abookId = $db->insert("addressbooks", $cols, [$vals]);
        $this->abooksDb = null;
        return $abookId;
    }

    /**
     * Updates some settings of an addressbook in the database.
     *
     * If the given ID does not refer to an addressbook of the logged-in user, nothing is changed.
     *
     * @param string $abookId ID of the addressbook
     * @param AbookSettings $pa Array with the settings to update
     */
    public function updateAddressbook(string $abookId, array $pa): void
    {
        $abookCfg = $this->getAddressbookConfig($abookId);
        $flagAttrs = AbstractDatabase::FLAGS_COLS['addressbooks']['fields'];
        $flagsInit = intval($abookCfg['flags']);
        [ $cols, $vals ] = $this->prepareDbRow($pa, self::ABOOK_SETTINGS, false, $flagAttrs, $flagsInit);

        $accountIds = $this->getAccountIds();
        if (!empty($cols) && !empty($accountIds)) {
            $db = Config::inst()->db();
            $db->update(['id' => $abookId, 'account_id' => $accountIds], $cols, $vals, "addressbooks");
            $this->abooksDb = null;
        }
    }

    /**
     * Deletes the given addressbooks from the database.
     *
     * @param list<string> $abookIds IDs of the addressbooks
     *
     * @param bool $skipTransaction If true, perform the operations without starting a transaction. Useful if the
     *                                operation is called as part of an enclosing transaction.
     *
     * @param bool $cacheOnly If true, only the cached addressbook data is deleted and the sync reset, but the
     *                        addressbook itself is retained.
     *
     * @throws Exception If any of the given addressbook IDs does not refer to an addressbook of the user.
     */
    public function deleteAddressbooks(array $abookIds, bool $skipTransaction = false, bool $cacheOnly = false): void
    {
        $infra = Config::inst();
        $db = $infra->db();

        if (empty($abookIds)) {
            return;
        }

        try {
            if (!$skipTransaction) {
                $db->startTransaction(false);
            }

            $userAbookIds = $this->getAddressbookIds(self::ABF_ALL);
            if (count(array_diff($abookIds, $userAbookIds)) > 0) {
                throw new Exception("request with IDs not referring to addressbooks of current user");
            }

            // we explicitly delete all data belonging to the addressbook, since
            // cascaded deletes are not supported by all database backends
            // ...custom subtypes
            $db->delete(['abook_id' => $abookIds], 'xsubtypes');

            // ...groups and memberships
            /** @psalm-var list<string> $delgroups */
            $delgroups = array_column($db->get(['abook_id' => $abookIds], ['id'], 'groups'), "id");
            if (!empty($delgroups)) {
                $db->delete(['group_id' => $delgroups], 'group_user');
            }

            $db->delete(['abook_id' => $abookIds], 'groups');

            // ...contacts
            $db->delete(['abook_id' => $abookIds]);

            // and finally the addressbooks themselves
            if ($cacheOnly) {
                $db->update(['id' => $abookIds], ['last_updated', 'sync_token'], ['0', ''], "addressbooks");
            } else {
                $db->delete(['id' => $abookIds], 'addressbooks');
            }

            if (!$skipTransaction) {
                $db->endTransaction();
            }
        } catch (Exception $e) {
            if (!$skipTransaction) {
                $db->rollbackTransaction();
            }
            throw $e;
        } finally {
            $this->abooksDb = null;
        }
    }

    /**
     * Discovers the addressbooks for the given account.
     *
     * The given account may be new or already exist in the database. In case of an existing account, it is expected
     * that the id field in $accountCfg is set to the corresponding ID.
     *
     * The function discovers the addressbooks for that account. Upon successful discovery, the account is
     * inserted/updated in the database, including setting of the last_discovered time. The auto-discovered addressbooks
     * of an existing account are updated accordingly, i.e. new addressbooks are inserted, and addressbooks that are no
     * longer discovered are also removed from the local db.
     *
     * @param AccountSettings $accountCfg Array with the settings for the account
     * @param AbookSettings $abookTmpl Array with default settings for new addressbooks
     * @return string The database ID of the account
     *
     * @throws Exception If the discovery failed for some reason. In this case, the state in the db remains unchanged.
     */
    public function discoverAddressbooks(array $accountCfg, array $abookTmpl): string
    {
        $infra = Config::inst();

        if ((!isset($accountCfg['discovery_url'])) || strlen($accountCfg['discovery_url']) === 0) {
            throw new Exception('Cannot discover addressbooks for an account lacking a discovery URI');
        }

        $account = Config::makeAccount($accountCfg);

        /** @psalm-var AccountSettings $accountCfg XXX temporary workaround for vimeo/psalm#8980 */

        $discover = $infra->makeDiscoveryService();
        $abooks = $discover->discoverAddressbooks($account);

        if (isset($accountCfg['id'])) {
            $accountId = $accountCfg['id'];
            $this->updateAccount($accountId, [ 'last_discovered' => (string) time() ]);

            // get locally existing addressbooks for this account
            $newbooks = []; // AddressbookCollection[] with new addressbooks at the server side
            $dbbooks = array_column(
                $this->getAddressbookConfigsForAccount($accountId, self::ABF_DISCOVERED),
                'id',
                'url'
            );
            foreach ($abooks as $abook) {
                $abookUri = $abook->getUri();
                if (isset($dbbooks[$abookUri])) {
                    unset($dbbooks[$abookUri]); // remove so we can sort out the deleted ones
                } else {
                    $newbooks[] = $abook;
                }
            }

            // delete all addressbooks we cannot find on the server anymore
            $this->deleteAddressbooks(array_values($dbbooks));
        } else {
            $accountCfg['last_discovered'] = (string) time();
            $accountId = $this->insertAccount($accountCfg);
            $newbooks = $abooks;
        }

        // store discovered addressbooks
        $accountCfg = $this->getAccountConfig($accountId);
        $abookTmpl['account_id'] = $accountId;
        $abookTmpl['discovered'] = '1';
        $abookTmpl['template'] = '0';
        $abookTmpl['sync_token'] = '';
        $abookNameTmpl = $abookTmpl['name'] ?? '%N';
        foreach ($newbooks as $abook) {
            $abookTmpl['name'] = $this->replacePlaceholdersAbookName($abookNameTmpl, $accountCfg, $abook);
            $abookTmpl['url'] = $abook->getUri();
            /** @psalm-var AbookSettings $abookTmpl XXX temporary workaround for vimeo/psalm#8980 */
            $this->insertAddressbook($abookTmpl);
        }

        return $accountId;
    }

    /**
     * Re-syncs the given addressbook.
     *
     * @param Addressbook $abook The addressbook object
     * @return int The duration in seconds that the sync took
     */
    public function resyncAddressbook(Addressbook $abook): int
    {
        // To avoid unnecessary work followed by roll back with other time-triggered refreshes, we temporarily
        // set the last_updated time such that the next due time will be five minutes from now
        $ts_delay = time() + 300 - $abook->getRefreshTime();
        $this->updateAddressbook($abook->getId(), ["last_updated" => (string) $ts_delay]);
        return $abook->resync();
    }

    /**
     * Replaces the placeholders in an addressbook name template.
     * @param string $name The name template
     * @param AccountCfg $accountCfg The configuration of the account the addressbook belongs to
     * @param AddressbookCollection $abook The addressbook collection object to query server-side properties
     * @return string
     */
    public function replacePlaceholdersAbookName(
        string $name,
        array $accountCfg,
        AddressbookCollection $abook
    ): string {
        $name = Utils::replacePlaceholdersUsername($name);
        $abName = '';
        $abDesc = '';

        // avoid network connection if none of the server-side fields are needed
        if (strpos($name, '%N') !== false || strpos($name, '%D') !== false) {
            $abName = $abook->getDisplayName();
            $abDesc = $abook->getDescription();
        }

        $transTable = [
            '%N' => $abName,
            '%D' => $abDesc,
            '%a' => $accountCfg['accountname'],
            '%c' => $abook->getBasename(),
            '%k' => $accountCfg['presetname'] ?? ''
        ];

        $name = strtr($name, $transTable);

        // if the template expands to an empty string, we use the last path component as default
        if (strlen($name) === 0) {
            $name = $abook->getBasename();
        }

        return $name;
    }

    /**
     * Prepares the row for a database insert or update operation from addressbook / account fields.
     *
     * Optionally checks that the given $settings contain values for all mandatory fields.
     *
     * @param array<string, null|string|int|bool> $settings
     *   The settings and their values.
     * @param array<string,SettingSpecification> $fieldspec
     *   The field specifications. Note that only fields that are part of this specification will be taken from
     *   $settings, others are ignored.
     * @param bool $isInsert
     *   True if the row is prepared for insertion, false if row is prepared for update. For insert, the row will be
     *   checked to include all mandatory attributes. For update, the row will be checked to not include non-updatable
     *   attributes.
     * @param array<string,int> $flagAttrs Attributes mapped to flags field and their bit positions
     * @param int $flagsInit
     *   The start value of the flags field. Only the values of application-level attributes contained in $settings will
     *   be changed.
     *
     * @return array{list<string>, list<string>}
     *   An array with two members: The first is an array of column names for insert/update. The second is the matching
     *   array of values.
     */
    private function prepareDbRow(
        array $settings,
        array $fieldspec,
        bool $isInsert,
        array $flagAttrs = [],
        int $flagsInit = 0
    ): array {
        $cols = []; // column names
        $vals = []; // columns values

        $setFlags = false; // if true, append the flags column with flagsInit value

        foreach ($fieldspec as $col => [ $mandatory, $updatable ]) {
            if (isset($settings[$col])) {
                if ($isInsert || $updatable) {
                    if (isset($flagAttrs[$col])) {
                        $setFlags = true;
                        $mask = 1 << $flagAttrs[$col];
                        if ((bool) $settings[$col]) {
                            $flagsInit |= $mask;
                        } else {
                            $flagsInit &= ~$mask;
                        }
                    } else {
                        $cols[] = $col;
                        $vals[] = (string) $settings[$col];
                    }
                } else {
                    throw new Exception(__METHOD__ . ": Attempt to update non-updatable field $col");
                }
            } elseif ($mandatory && $isInsert) {
                throw new Exception(__METHOD__ . ": Mandatory field $col missing");
            }
        }

        if ($setFlags) {
            $cols[] = 'flags';
            $vals[] = (string) $flagsInit;
        }

        return [ $cols, $vals ];
    }
}

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