<?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);

use MStilkerich\RCMCardDAV\{Config, DataConversion};
use MStilkerich\RCMCardDAV\Frontend\{AddressbookManager,RcmInterface,UI};
use Sabre\VObject\Component\VCard;

// This supports a self-contained tarball installation of the plugin, at the risk of having conflicts with other
// versions of the library installed in the global roundcube vendor directory (-> use not recommended)
// phpcs:disable PSR1.Files.SideEffects -- need to load outside carddav class as it implements RcmInterface
if (file_exists(dirname(__FILE__) . "/vendor/autoload.php")) {
    include_once dirname(__FILE__) . "/vendor/autoload.php";
}

/**
 * @psalm-import-type SaveDataFromDC from DataConversion
 * @psalm-type RcAddressbookInfo = array{id: string, name: string, groups: bool, autocomplete: bool, readonly: bool}
 */
// phpcs:ignore PSR1.Classes.ClassDeclaration,Squiz.Classes.ValidClassName -- class name(space) expected by roundcube
class carddav extends rcube_plugin implements RcmInterface
{
    /**
     * The version of this plugin.
     *
     * During development, it is set to the last release and added the suffix +dev.
     */
    public const PLUGIN_VERSION = 'v5.1.3';

    /**
     * Information about this plugin that is queried by roundcube.
     */
    private const PLUGIN_INFO = [
        'name' => 'carddav',
        'vendor' => 'Michael Stilkerich, Benjamin Schieder',
        'version' => self::PLUGIN_VERSION,
        'license' => 'GPL-2.0',
        'uri' => 'https://github.com/mstilkerich/rcmcarddav/'
    ];

    /**
     * Regular expression defining task(s) to bind with
     * @var string
     */
    public $task = 'addressbook|login|mail|settings|calendar';

    /**
     * The addressbook manager.
     * @var AddressbookManager
     */
    private $abMgr;

    /**
     * Provide information about this plugin.
     *
     * @return array Meta information about a plugin or false if not implemented.
     * As hash array with the following keys:
     *      name: The plugin name
     *    vendor: Name of the plugin developer
     *   version: Plugin version name
     *   license: License name (short form according to http://spdx.org/licenses/)
     *       uri: The URL to the plugin homepage or source repository
     *   src_uri: Direct download URL to the source code of this plugin
     *   require: List of plugins required for this one (as array of plugin names)
     */
    public static function info(): array
    {
        return self::PLUGIN_INFO;
    }

    /**
     * Default constructor.
     *
     * @param rcube_plugin_api $api Plugin API
     */
    public function __construct($api)
    {
        parent::__construct($api);

        // we do not use the roundcube mechanism to save preferences but store preferences to custom DB tables
        $this->allowed_prefs = [];

        // constructor of AddressbookManager is empty, so it is safe to construct the object here
        $this->abMgr = new AddressbookManager();
    }

    public function init(): void
    {
        try {
            $this->addHook('login_after', [$this, 'afterLogin']);

            // Until we have a logged on user, we have no business other than registering the afterLogin hook
            // Specifically, we delay the other initializations until this point since some actions (particularly
            // reading the admin presets, which may require substitution of the username in URLs) require the logged on
            // user to be available
            if (!isset($_SESSION['user_id'])) {
                return;
            }

            $this->basicInit();
            $this->finalizeInit();
        } catch (Exception $e) {
            $infra = Config::inst();
            $logger = $infra->logger();
            $logger->error("Could not init rcmcarddav: " . $e->getMessage());
        }
    }

    /**
     * Performs basic initialization of the plugin.
     *
     * Particularly it reads the admin settings and initialized the loggers to the configured log levels.
     */
    private function basicInit(): void
    {
        $infra = Config::inst();

        // register this object as the roundcube adapter object
        $infra->rc($this);

        // initialize carddavclient library
        MStilkerich\CardDavClient\Config::init($infra->logger(), $infra->httpLogger());
    }

    /**
     * Registers the plugin hook functions and the user's addressbooks with roundcube.
     */
    private function finalizeInit(): void
    {
        $infra = Config::inst();
        $rc = $infra->rc();

        $rcmail = rcmail::get_instance();
        $rcTask = $rcmail->task;

        $rc->addTexts('localization/');

        $rc->addHook('addressbooks_list', [$this, 'listAddressbooks']);
        $rc->addHook('addressbook_get', [$this, 'getAddressbook']);
        $rc->addHook('addressbook_export', [$this, 'exportVCards']);
        // if preferences are configured as hidden by the admin, don't register the hooks handling preferences
        $admPrefs = $infra->admPrefs();
        if (!$admPrefs->hidePreferences && $rcTask == "settings") {
            new UI($this->abMgr);
        }

        // use this address book for autocompletion queries
        // (maybe this should be configurable by the user?)
        $config = rcube::get_instance()->config;
        $sources = (array) $config->get('autocomplete_addressbooks', ['sql']);

        $carddav_sources = array_map(
            function (string $id): string {
                return "carddav_$id";
            },
            $this->abMgr->getAddressbookIds()
        );

        $config->set('autocomplete_addressbooks', array_merge($sources, $carddav_sources));

        $specialAbooks = $admPrefs->getSpecialAddressbooks($this->abMgr, $infra);
        foreach ($specialAbooks as $type => $abookId) {
            // Set this option as immutable; otherwise it might be overridden from roundcube user preferences (#391)
            $config->set($type, "carddav_$abookId", true);
        }
    }

    /***************************************************************************************
     *                                  ROUNDCUBE ADAPTER
     **************************************************************************************/

    public function locText(string $msgId, array $vars = []): string
    {
        $locMsg = $this->gettext(["name" => $msgId, "vars" => $vars]);
        return rcube::Q($locMsg);
    }

    public function inputValue(string $id, bool $allowHtml, int $source = rcube_utils::INPUT_POST): ?string
    {
        $value = rcube_utils::get_input_value($id, $source, $allowHtml);

        return is_string($value) ? $value : null;
    }

    public function showMessage(string $msg, string $msgType = 'notice', bool $override = false, int $timeout = 0): void
    {
        $rcube = rcube::get_instance();
        $output = $rcube->output;

        if ($output instanceof rcmail_output) {
            $output->show_message($msg, $msgType, null, $override, $timeout);
        }
    }

    public function clientCommand(string $method, ...$arguments): void
    {
        $rcube = rcube::get_instance();
        $output = $rcube->output;

        if ($output instanceof rcmail_output) {
            $output->command($method, ...$arguments);
        }
    }

    public function addHook(string $hook, callable $callback): void
    {
        $this->add_hook($hook, $callback);
    }

    public function registerAction(string $action, callable $callback): void
    {
        $this->register_action($action, $callback);
    }

    public function addTexts(string $dir): void
    {
        $this->add_texts($dir, true);
    }

    public function includeCSS(string $cssFile): void
    {
        $skinPath = $this->local_skin_path();
        $this->include_stylesheet("$skinPath/$cssFile");
    }

    public function includeJS(string $jsFile, bool $rcInclude = false): void
    {
        if ($rcInclude) {
            $rcube = rcube::get_instance();
            /** @psalm-var rcmail_output_html $output */
            $output = $rcube->output;
            $output->include_script($jsFile);
        } else {
            $this->include_script($jsFile);
        }
    }

    public function addGuiObject(string $obj, string $id): void
    {
        $rcube = rcube::get_instance();

        /** @psalm-var rcmail_output_html $output */
        $output = $rcube->output;
        $output->add_gui_object($obj, $id);
    }

    public function setPageTitle(string $title): void
    {
        $rcube = rcube::get_instance();

        /** @psalm-var rcmail_output_html $output */
        $output = $rcube->output;
        $output->set_pagetitle($title);
    }

    public function addTemplateObjHandler(string $name, callable $func): void
    {
        $rcube = rcube::get_instance();

        /** @psalm-var rcmail_output_html $output */
        $output = $rcube->output;
        $output->add_handler($name, $func);
    }

    public function setEnv(string $name, $value, bool $addToJs = true): void
    {
        $rcube = rcube::get_instance();
        $output = $rcube->output;
        if ($output instanceof rcmail_output_html) {
            $output->set_env($name, $value, $addToJs);
        }
    }

    public function sendTemplate(string $templ, bool $exit = true): void
    {
        $rcube = rcube::get_instance();

        /** @psalm-var rcmail_output_html $output */
        $output = $rcube->output;
        $output->send($templ, $exit);
    }

    /**
     * @param array<string,string> $attrib
     */
    public function requestForm(array $attrib, string $content): string
    {
        $rcube = rcube::get_instance();

        /** @psalm-var rcmail_output_html $output */
        $output = $rcube->output;
        return $output->request_form($attrib, $content);
    }

    /***************************************************************************************
     *                                    HOOK FUNCTIONS
     **************************************************************************************/

    public function afterLogin(): void
    {
        // this is needed because when carddav::init() was invoked, SESSION['user_id'] was not yet available, and
        // therefore we delayed the plugin initialization to this point
        $this->basicInit();
        $infra = Config::inst();
        $logger = $infra->logger();
        $admPrefs = $infra->admPrefs();
        $db = $infra->db();
        $abMgr = $this->abMgr;

        // Migrate database schema to the current version if needed
        try {
            $logger->debug(__METHOD__);

            $scriptDir = __DIR__ . "/dbmigrations/";
            $config = rcube::get_instance()->config;
            $dbprefix = (string) $config->get('db_prefix', "");
            $db->checkMigrations($dbprefix, $scriptDir);
        } catch (Exception $e) {
            $logger->error("Error execution DB schema migrations: " . $e->getMessage());
        }

        // Initialize presets
        $admPrefs->initPresets($abMgr, $infra);

        // Perform rediscoveries when rediscover_time has passed
        // We do this after initPresets() so we do not perform a rediscovery for presets that the admin may have
        // deleted. For new accounts added by initPresets(), a rediscovery will not be done because rediscover_time is
        // just now.
        $accountIds = $abMgr->getAccountIds();
        foreach ($accountIds as $accountId) {
            $accountCfg = $abMgr->getAccountConfig($accountId);

            // if there is no discovery URL, we cannot perform discovery; this should only happen for presets
            if (strlen($accountCfg['discovery_url'] ?? '') === 0) {
                continue;
            }

            if (($accountCfg['last_discovered'] + $accountCfg['rediscover_time']) < time()) {
                try {
                    $abookTmpl = $admPrefs->getAddressbookTemplate($abMgr, $accountId);
                    $logger->info("Performing re-discovery for account $accountId");
                    $abMgr->discoverAddressbooks($accountCfg, $abookTmpl);
                } catch (Exception $e) {
                    $logger->error("Rediscovery of account $accountId failed: {$e->getMessage()}");
                }
            }
        }

        // now register the addressbooks with roundcube
        $this->finalizeInit();
    }

    /**
     * Adds the user's CardDAV addressbooks to Roundcube's addressbook list.
     *
     * @psalm-param array{sources: array<string, RcAddressbookInfo>} $p
     * @return array{sources: array<string, RcAddressbookInfo>}
     */
    public function listAddressbooks(array $p): array
    {
        $infra = Config::inst();
        $logger = $infra->logger();

        try {
            $logger->debug(__METHOD__);
            $abMgr = $this->abMgr;

            foreach ($abMgr->getAddressbookIds() as $abookId) {
                $abookCfg = $abMgr->getAddressbookConfig($abookId);

                $p['sources']["carddav_$abookId"] = [
                    'id' => "carddav_$abookId",
                    'name' => $abookCfg['name'],
                    'groups' => true,
                    'autocomplete' => true,
                    'readonly' => ($abookCfg['readonly'] != '0')
                ];
            }
        } catch (Exception $e) {
            $logger->error("Error reading carddav addressbooks: {$e->getMessage()}");
        }

        return $p;
    }

    /**
     * Hook called by roundcube to retrieve the instance of an addressbook.
     *
     * @param array $p The passed array contains the keys:
     *     id: ID of the addressbook as passed to roundcube in the listAddressbooks hook.
     *     writeable: Whether the addressbook needs to be writeable (checked by roundcube after returning an instance).
     * @psalm-param array{id: ?string} $p
     * @return array Returns the passed array extended by a key instance pointing to the addressbook object.
     *     If the addressbook is not provided by the plugin, simply do not set the instance and return what was passed.
     */
    public function getAddressbook(array $p): array
    {
        $infra = Config::inst();
        $logger = $infra->logger();
        $rc = $infra->rc();
        $abMgr = $this->abMgr;

        $abookId = $p['id'] ?? 'null';

        try {
            $logger->debug(__METHOD__ . "($abookId)");

            if (preg_match(";^carddav_(\d+)$;", $abookId, $match)) {
                $abookId = $match[1];
                $abook = $abMgr->getAddressbook($abookId);
                $p['instance'] = $abook;

                // refresh the address book if the update interval expired this requires a completely initialized
                // Addressbook object, so it needs to be at the end of this constructor
                $ts_syncdue = $abook->checkResyncDue();
                if ($ts_syncdue <= 0) {
                    $msgParam = [ 'name' => $abook->get_name() ];
                    try {
                        $msgParam['duration'] = (string) $abMgr->resyncAddressbook($abook);
                        $rc->showMessage($rc->locText('AbSync_msg_ok', $msgParam), 'notice', false);
                    } catch (Exception $e) {
                        $msgParam['errormsg'] = $e->getMessage();
                        $logger->error("Failed to sync addressbook: " . $msgParam['errormsg']);
                        $rc->showMessage($rc->locText('AbSync_msg_fail', $msgParam), 'warning', false);
                    }
                }
            }
        } catch (Exception $e) {
            $logger->error("Error loading carddav addressbook $abookId: {$e->getMessage()}");
        }

        return $p;
    }

    /**
     * Prepares the exported VCards when the user requested VCard export in roundcube.
     *
     * By adding a "vcard" member to a save_data set, we can override roundcube's own VCard creation
     * from the save_data and provide the VCard directly.
     *
     * Beware: This function is called also for non-carddav addressbooks, therefore it must handle entries
     * that cannot be found in the carddav addressbooks.
     *
     * @param array{result: rcube_result_set} $saveDataSet A result set as provided by Addressbook::list_records
     * @return array{abort: bool, result: rcube_result_set} The result set with added vcard members in each save_data
     */
    public function exportVCards(array $saveDataSet): array
    {
        /** @psalm-var SaveDataFromDC $save_data */
        foreach ($saveDataSet["result"]->records as &$save_data) {
            if (isset($save_data["_carddav_vcard"])) {
                $vcf = DataConversion::exportVCard($save_data["_carddav_vcard"], $save_data);
                $save_data["vcard"] = $vcf;
            }
        }

        return [ "result" => $saveDataSet["result"], "abort" => false ];
    }
}

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