<?php
/**
 * @file classes/core/EntityDAO.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2000-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class EntityDAO
 *
 * @brief A base class for DAOs that read and write an entity to the database
 */

namespace PKP\core;

use Exception;
use Illuminate\Support\Facades\DB;
use PKP\db\DAO;
use PKP\services\PKPSchemaService;

/**
 * @template T of DataObject
 */
abstract class EntityDAO
{
    /** @var string One of the \PKP\services\PKPSchemaService::SCHEMA_... constants */
    public $schema;

    /** @var string The name of the primary table for this entity */
    public $table;

    /** @var string The name of the settings table for this entity */
    public $settingsTable;

    /** @var string The column name for the object id in primary and settings tables */
    public $primaryKeyColumn;

    /** @var array Maps schema properties for the primary table to their column names */
    public $primaryTableColumns = [];

    /**
     * @var array Map schema properties to the primary table
     *
     * An array mapping the property names of an entity to the
     * correct column in the database table.
     *
     * Example:
     *
     * ```
     * [
     *  'id' => 'announcement_id',
     *  'datePosted' => 'date_posted',
     * }
     * ```
     *
     * Only include properties stored in self::$table. Properties
     * stored in self::$settingsTable do not need to be mapped.
     */
    public $primaryKeyColumns;

    /**
     * @var DAO An instance of PKP\db\DAO
     *
     * This provides access to a few methods that are
     * still shared with the deprecated DAOs.
     *
     * @deprecated 3.4
     */
    public $deprecatedDao;

    /** @var PKPSchemaService<T> $schemaService */
    protected $schemaService;

    /**
     * Constructor
     */
    public function __construct(PKPSchemaService $schemaService)
    {
        $this->deprecatedDao = new DAO();
        $this->schemaService = $schemaService;
    }

    /**
     * Creates a new DataObject
     *
     * @return T
     */
    public function newDataObject()
    {
        throw new Exception('Not implemented');
    }

    /**
     * Convert a row from the database query into a DataObject
     *
     * @return T
     */
    public function fromRow(object $row): DataObject
    {
        $schema = $this->schemaService->get($this->schema);

        $object = $this->newDataObject();

        foreach ($this->primaryTableColumns as $propName => $column) {
            if (property_exists($row, $column)) {
                $object->setData(
                    $propName,
                    $this->convertFromDB($row->{$column}, $schema->properties->{$propName}->type, true)
                );
            }
        }

        if ($this->settingsTable) {
            $rows = DB::table($this->settingsTable)
                ->where($this->primaryKeyColumn, '=', $row->{$this->primaryKeyColumn})
                ->get();

            $rows->each(function ($row) use ($object, $schema) {
                if (!empty($schema->properties->{$row->setting_name})) {
                    $object->setData(
                        $row->setting_name,
                        $this->convertFromDB(
                            $row->setting_value,
                            $schema->properties->{$row->setting_name}->type
                        ),
                        empty($row->locale) ? null : $row->locale
                    );
                }
            });
        }

        return $object;
    }

    /**
     * Insert an object into the database
     * @param T $object
     */
    protected function _insert(DataObject $object): int
    {
        $schemaService = $this->schemaService;
        $schema = $schemaService->get($this->schema);
        $sanitizedProps = $schemaService->sanitize($this->schema, $object->_data);

        $primaryDbProps = $this->getPrimaryDbProps($object);

        if (empty($primaryDbProps)) {
            throw new Exception('Tried to insert ' . get_class($object) . ' without any properties for the ' . $this->table . ' table.');
        }

        DB::table($this->table)->insert($primaryDbProps);
        $object->setId((int) DB::getPdo()->lastInsertId());

        // Add additional properties to settings table if they exist
        if ($this->settingsTable && count($sanitizedProps) !== count($primaryDbProps)) {
            foreach ($schema->properties as $propName => $propSchema) {
                if (!isset($sanitizedProps[$propName]) || array_key_exists($propName, $this->primaryTableColumns)) {
                    continue;
                }
                if (!empty($propSchema->multilingual)) {
                    foreach ($sanitizedProps[$propName] as $localeKey => $localeValue) {
                        DB::table($this->settingsTable)->insert([
                            $this->primaryKeyColumn => $object->getId(),
                            'locale' => $localeKey,
                            'setting_name' => $propName,
                            'setting_value' => $this->convertToDB($localeValue, $schema->properties->{$propName}->type),
                        ]);
                    }
                } else {
                    DB::table($this->settingsTable)->insert([
                        $this->primaryKeyColumn => $object->getId(),
                        'setting_name' => $propName,
                        'setting_value' => $this->convertToDB($sanitizedProps[$propName], $schema->properties->{$propName}->type),
                    ]);
                }
            }
        }

        return $object->getId();
    }

    /**
     * Update an object in the database
     * @param T $object
     */
    protected function _update(DataObject $object)
    {
        $schemaService = $this->schemaService;
        $schema = $schemaService->get($this->schema);
        $sanitizedProps = $schemaService->sanitize($this->schema, $object->_data);

        $primaryDbProps = $this->getPrimaryDbProps($object);

        DB::table($this->table)
            ->where($this->primaryKeyColumn, '=', $object->getId())
            ->update($primaryDbProps);

        if ($this->settingsTable) {
            $deleteSettings = [];
            foreach ($schema->properties as $propName => $propSchema) {
                if (array_key_exists($propName, $this->primaryTableColumns)) {
                    continue;
                } elseif (!isset($sanitizedProps[$propName])) {
                    $deleteSettings[] = $propName;
                    continue;
                }
                if (!empty($propSchema->multilingual)) {
                    foreach ($sanitizedProps[$propName] as $localeKey => $localeValue) {
                        // Delete rows with a null value
                        if (is_null($localeValue)) {
                            DB::table($this->settingsTable)
                                ->where($this->primaryKeyColumn, '=', $object->getId())
                                ->where('setting_name', '=', $propName)
                                ->where('locale', '=', $localeKey)
                                ->delete();
                        } else {
                            DB::table($this->settingsTable)
                                ->updateOrInsert(
                                    [
                                        $this->primaryKeyColumn => $object->getId(),
                                        'locale' => $localeKey,
                                        'setting_name' => $propName,
                                    ],
                                    [
                                        'setting_value' => $this->convertToDB($localeValue, $schema->properties->{$propName}->type),
                                    ]
                                );
                        }
                    }
                } else {
                    DB::table($this->settingsTable)
                        ->updateOrInsert(
                            [
                                $this->primaryKeyColumn => $object->getId(),
                                'locale' => '',
                                'setting_name' => $propName,
                            ],
                            [
                                'setting_value' => $this->convertToDB($sanitizedProps[$propName], $schema->properties->{$propName}->type),
                            ]
                        );
                }
            }

            if (count($deleteSettings)) {
                DB::table($this->settingsTable)
                    ->where($this->primaryKeyColumn, '=', $object->getId())
                    ->whereIn('setting_name', $deleteSettings)
                    ->delete();
            }
        }
    }

    /**
     * Delete an object from the database
     * @param T $object
     */
    protected function _delete(DataObject $object)
    {
        $this->deleteById($object->getId());
    }

    /**
     * Delete an object from the database by its id
     */
    public function deleteById(int $id)
    {
        DB::table($this->table)
            ->where($this->primaryKeyColumn, '=', $id)
            ->delete();
    }

    /**
     * Prepare data to be inserted into the primary table
     *
     * Compiles the properties of a DataObject into a key/value
     * array that maps them to the primary table columns.
     *
     * @see $this->primaryTableColumns
     * @param T $object
     */
    protected function getPrimaryDbProps(DataObject $object): array
    {
        $schema = $this->schemaService->get($this->schema);
        $sanitizedProps = $this->schemaService->sanitize($this->schema, $object->_data);

        $primaryDbProps = [];
        foreach ($this->primaryTableColumns as $propName => $columnName) {
            if ($propName !== 'id' && array_key_exists($propName, $sanitizedProps)) {
                $primaryDbProps[$columnName] = $this->convertToDB($sanitizedProps[$propName] ?? null, $schema->properties->{$propName}->type, true);
                // Convert empty string values for DATETIME columns into null values
                // because an empty string can not be saved to a DATETIME column
                if ($primaryDbProps[$columnName] === ''
                        && isset($schema->properties->{$propName}->validation)
                        && (
                            in_array('date_format:Y-m-d H:i:s', $schema->properties->{$propName}->validation)
                            || in_array('date_format:Y-m-d', $schema->properties->{$propName}->validation)
                        )
                ) {
                    $primaryDbProps[$columnName] = null;
                }
            }
        }

        return $primaryDbProps;
    }

    /**
     * @copydoc DAO::convertFromDB()
     */
    protected function convertFromDB($value, string $type, bool $nullable = false)
    {
        return $this->deprecatedDao->convertFromDB($value, $type, $nullable);
    }

    /**
     * @copydoc DAO::convertToDB()
     */
    protected function convertToDB($value, string $type, bool $nullable = false)
    {
        return $this->deprecatedDao->convertToDB($value, $type, $nullable);
    }
}
