<?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\Db;

use MStilkerich\RCMCardDAV\DataConversion;

/**
 * Access interface for the roundcube database.
 *
 * @psalm-type DbConditions = string|array<string, null|string|string[]>|list<DbAndCondition>
 * @psalm-type DbGetResult = array<string,?string>
 * @psalm-type DbGetResults = list<DbGetResult>
 * @psalm-type DbInsRow = list<?string>
 * @psalm-type DbGetOptions = array{limit?: array{int,int}, order?: list<string>, count?: true}
 *
 * @psalm-type FullAccountRow = 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
 * }
 * @psalm-type FullAbookRow = array{
 *     id: string,
 *     account_id: string,
 *     name: string,
 *     url: string,
 *     last_updated: numeric-string,
 *     refresh_time: numeric-string,
 *     sync_token: string,
 *     flags: numeric-string
 * }
 * @psalm-type FlagColSpec = array{
 *     default: int,
 *     fields:  array<string, int>,
 * }
 *
 * @psalm-import-type SaveDataFromDC from DataConversion
 * @psalm-import-type SaveDataMultiField from DataConversion
 */
abstract class AbstractDatabase
{
    /**
     * @var array<string, FlagColSpec> Specification of the optionally available "flags" column of a DB table.
     *
     * This column can be used to store application-level boolean attributes of an entity as bits of an integer DB
     * column. This array defines:
     *   - which tables have a flags column (as determined by having a key in this table)
     *   - the default value of the flags column according to the DB schema
     *   - the application-level attribute names and their respective bit position
     */
    public const FLAGS_COLS = [
        'addressbooks' => [
            'default' => 5, // the default value of the flags column as defined in the schema
            'fields' => [   // application-level bool attributes and their bit positions
                'active'               => 0,
                'use_categories'       => 1,
                'discovered'           => 2,
                'readonly'             => 3,
                'require_always_email' => 4,
                'template'             => 5, // pseudo-addressbook that stores initial settings for new addressbooks
            ]
        ],
        'accounts' => [
            'default' => 0, // the default value of the flags column as defined in the schema
            'fields' => [   // application-level bool attributes and their bit positions
                'preemptive_basic_auth' => 0,
                'ssl_noverify'          => 1,
            ]
        ],
    ];

    /**
     * @var string Separator string used when several values are combined in one DB column.
     *
     * This is relevant to the email column of the contacts table, where several email addresses can be stored.
     */
    public const MULTIVAL_SEP = ", ";

    /**
     * Starts a transaction on the internal DB connection.
     *
     * Note that all queries in the transaction must be done using the same Database object, to make sure they use the
     * same database connection.
     *
     * @param bool $readonly True if the started transaction only queries, but does not modify data.
     */
    abstract public function startTransaction(bool $readonly = true): void;

    /**
     * Commits the transaction on the internal DB connection.
     */
    abstract public function endTransaction(): void;

    /**
     * Rolls back the transaction on the internal DB connection.
     */
    abstract public function rollbackTransaction(): void;

    /**
     * Checks if the database schema is up-to-date and performs migrations if needed.
     *
     * @param string $dbPrefix The optional prefix to all database table names as configured in Roundcube.
     * @param string $scriptDir Path of the parent directory containing all the migration scripts, each in a subdir.
     */
    abstract public function checkMigrations(string $dbPrefix, string $scriptDir): void;

    /**
     * Inserts new rows to a database table.
     *
     * @param string $table The database table to store the row to.
     * @param list<string> $cols Database column names of attributes to insert.
     * @param list<DbInsRow> $rows An array of rows to insert. Indexes of each row must match those in $cols.
     * @return string The database id of the last created database row. Empty string if the table has no ID column.
     */
    abstract public function insert(string $table, array $cols, array $rows): string;

    /**
     * Updates records in a database table.
     *
     * @param DbConditions $conditions Selects the rows to update.
     * @param list<string> $cols  Database column names of attributes to update.
     * @param DbInsRow     $vals  The values to set into the column specified by $cols at the corresponding index.
     * @param string $table       Name of the database table to select from, without the carddav_ prefix.
     * @return int                The number of rows updated.
     *
     * @see normalizeConditions() for a description of $conditions
     * @see Database::getConditionsQuery()
     */
    abstract public function update($conditions, array $cols, array $vals, string $table = 'contacts'): int;

    /**
     * Gets rows from a database table.
     *
     * @param DbConditions $conditions Selects the rows to get.
     * @param list<string> $cols Database column names to select. Empty array means all columns.
     * @param string $table       Name of the database table to select from, without the carddav_ prefix.
     * @param DbGetOptions $options Associative array with extra options, mapping option name => option setting
     *                            Currently supported:
     *                              - limit: Value is an array of two integers [ $offset, $numrows ]. Limits the
     *                                       returned rows to maximum $numrows starting at $offset of the full result.
     *                              - order: Value is a list of column names to order by in ascending order. Prefix the
     *                                       column name with "!" (e.g. "!firstname") to sort in descending order.
     *                              - count: Value must be true.
     *                                       Execute an aggregate query on the columns in $cols. The result will be a
     *                                       single row, where each column's value is the number of non-null values in
     *                                       the query result. The special column '*' can be used to count the rows.
     * @return DbGetResults       If no error occurred, returns an array of associative row arrays with the
     *                            matching rows. Each row array has the fieldnames as keys and the corresponding
     *                            database value as value.
     *
     * @see normalizeConditions() for a description of $conditions
     * @see Database::getConditionsQuery()
     */
    abstract public function get(
        $conditions,
        array $cols = [],
        string $table = 'contacts',
        array $options = []
    ): array;

    /**
     * Like {@see get()}, but expects exactly a single row as result.
     *
     * If the query yields fewer or more than one row, an exception is thrown.
     *
     * @param DbConditions $conditions Selects the row to lookup.
     * @param list<string> $cols Database column names to select. Empty array means all columns.
     * @return DbGetResult If no error occurred, returns an associative row array with the
     *                     matching row, where keys are fieldnames and their value is the corresponding database
     *                     value of the field in the result row.
     *
     * @see get() For other parameter descriptions
     * @see normalizeConditions() for a description of $conditions
     * @see Database::getConditionsQuery()
     */
    abstract public function lookup($conditions, array $cols = [], string $table = 'contacts'): array;

    /**
     * Deletes rows from a database table.
     *
     * @param DbConditions $conditions Selects the rows to delete.
     * @param string $table       Name of the database table to select from, without the carddav_ prefix.
     * @return int                The number of rows deleted.
     *
     * @see normalizeConditions() for a description of $conditions
     * @see Database::getConditionsQuery()
     */
    abstract public function delete($conditions, string $table = 'contacts'): int;

    /**
     * Stores a contact to the local database.
     *
     * @param string $abookid Database ID of the addressbook the contact shall be inserted to
     * @param string $etag of the VCard in the given version on the CardDAV server
     * @param string $uri path to the VCard on the CardDAV server
     * @param string $vcfstr string representation of the VCard
     * @param SaveDataFromDC $save_data associative array containing the roundcube save data for the contact
     * @param ?string $dbid optionally, database id of the contact if the store operation is an update
     *
     * @return string The database id of the created or updated card.
     */
    public function storeContact(
        string $abookid,
        string $etag,
        string $uri,
        string $vcfstr,
        array $save_data,
        ?string $dbid = null
    ): string {
        // build email search string
        $email_keys = preg_grep('/^email(:|$)/', array_keys($save_data));
        $email_addrs = [];
        foreach ($email_keys as $email_key) {
            /** @psalm-var SaveDataMultiField $save_data[$email_key] */
            $email_addrs = array_merge($email_addrs, $save_data[$email_key]);
        }
        $save_data['email'] = implode(', ', $email_addrs);

        // extra columns for the contacts table
        $xcol_all = array('firstname','surname','organization','showas','email');
        $xcol = [];
        $xval = [];
        foreach ($xcol_all as $k) {
            if (isset($save_data[$k])) {
                /** @var string $v */
                $v = $save_data[$k];
                $xcol[] = $k;
                $xval[] = $v;
            }
        }

        return $this->storeAddressObject(
            'contacts',
            $abookid,
            $etag,
            $uri,
            $vcfstr,
            $save_data['cuid'] ?? null,
            $save_data['name'],
            $dbid,
            $xcol,
            $xval
        );
    }

    /**
     * Stores a group in the database.
     *
     * If the group is based on a KIND=group vcard, the record must be stored with ETag, URI and VCard. Otherwise, if
     * the group is derived from a CATEGORIES property of a contact VCard, the ETag, URI and VCard must be set to NULL
     * to indicate this.
     *
     * @param string $abookid Database ID of the addressbook the group shall be inserted to
     * @param SaveDataFromDC $save_data associative array containing at least name and cuid (card UID)
     * @param ?string $dbid optionally, database id of the group if the store operation is an update
     * @param ?string $etag of the VCard in the given version on the CardDAV server
     * @param ?string $uri path to the VCard on the CardDAV server
     * @param ?string $vcfstr string representation of the VCard
     *
     * @return string The database id of the created or updated card.
     */
    public function storeGroup(
        string $abookid,
        array $save_data,
        ?string $dbid = null,
        ?string $etag = null,
        ?string $uri = null,
        ?string $vcfstr = null
    ): string {
        return $this->storeAddressObject(
            'groups',
            $abookid,
            $etag,
            $uri,
            $vcfstr,
            $save_data['cuid'] ?? null,
            $save_data['name'],
            $dbid
        );
    }

    /**
     * Inserts a new contact or group into the database, or updates an existing one.
     *
     * If the address object is not backed by an object on the server side (CATEGORIES-type groups), the parameters
     * $etag, $uri and $vcfstr are not applicable and shall be passed as NULL.
     *
     * @param string $table The target table, without carddav_ prefix (contacts or groups)
     * @param string $abookid The database ID of the addressbook the address object belongs to.
     * @param ?string $etag The ETag value of the CardDAV-server address object that this object is created from.
     * @param ?string $uri  The URI of the CardDAV-server address object that this object is created from.
     * @param ?string $vcfstr The VCard string of the CardDAV-server address object that this object is created from.
     * @param ?string $cuid The VCard UID if applicable, otherwise null.
     * @param string $name The name of the address object.
     * @param ?string $dbid If an existing object is updated, this specifies its database id.
     * @param list<string> $xcol Database column names of attributes to insert.
     * @param DbInsRow $xval The values to insert into the column specified by $xcol at the corresponding index.
     * @return string The database id of the created or updated card.
     */
    protected function storeAddressObject(
        string $table,
        string $abookid,
        ?string $etag,
        ?string $uri,
        ?string $vcfstr,
        ?string $cuid,
        string $name,
        ?string $dbid,
        array $xcol = [],
        array $xval = []
    ): string {
        $xcol[] = 'name';
        $name = $name;
        $xval[] = $name;

        if (isset($etag)) {
            $xcol[] = 'etag';
            $xval[] = $etag;
        }

        if (isset($vcfstr)) {
            $xcol[] = 'vcard';
            $xval[] = $vcfstr;
        }

        if (isset($dbid)) {
            $this->update($dbid, $xcol, $xval, $table);
        } else {
            $xcol[] = 'abook_id';
            $xval[] = $abookid;

            if (isset($uri)) {
                $xcol[] = 'uri';
                $xval[] = $uri;
            }
            if (isset($cuid)) {
                $xcol[] = 'cuid';
                $xval[] = $cuid;
            }

            $dbid = $this->insert($table, $xcol, [$xval]);
        }

        return $dbid;
    }

    /**
     * Normalizes the short form of specifying DB filter conditions to DbAndCondition[].
     *
     * The following short forms are supported for $conditions:
     *   - Empty array: No filter at all
     *   - Single string value: matched against the "id" column
     *   - Associative array mapping "field specifier" => "value specifier": All match criteria must match.
     *
     * The normalized form is an array of DbAndCondition.
     *
     * @param DbConditions $conditions See above for description.
     * @return list<DbAndCondition>
     *
     * @see DbOrCondition For a description on the format of field/value specifiers.
     */
    protected function normalizeConditions($conditions): array
    {
        if (is_string($conditions)) {
            $cond = [ new DbAndCondition(new DbOrCondition("id", $conditions)) ];
        } elseif (isset($conditions[0]) && $conditions[0] instanceof DbAndCondition) {
            /** @var list<DbAndCondition> $cond */
            $cond = $conditions;
        } else {
            $cond = [];
            /** @var array<string, null|string|string[]> $conditions */
            foreach ($conditions as $fieldSpec => $valueSpec) {
                $cond[] = new DbAndCondition(new DbOrCondition($fieldSpec, $valueSpec));
            }
        }

        return $cond;
    }
}

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